From 95065330d7c743f657a8c96cc8f7bbbea7967e41 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:39:48 +0800 Subject: [PATCH] chore: share README gate Markdown scanning --- scripts/markdown.ts | 32 +++++++++++++++++++ scripts/verify-package-readme-limitations.ts | 28 ++-------------- .../verify-package-readme-model-experience.ts | 28 ++-------------- 3 files changed, 37 insertions(+), 51 deletions(-) diff --git a/scripts/markdown.ts b/scripts/markdown.ts index 8aa0f7d935..511a04a736 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -5,6 +5,14 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' +/** One authored Markdown line outside fenced code. */ +export interface MarkdownProseLine { + /** 1-based source line number. */ + index: number + /** Source text without normalization. */ + raw: string +} + /** Parse GitHub-flavored Markdown with the repository's standard extensions. */ export function parseMarkdown(source: string): Nodes { return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) @@ -21,3 +29,27 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v for (const child of node.children) visitMarkdown(child, visitor) } } + +/** + * Return source lines outside backtick or tilde fences. + * @param source - Markdown source whose prose should be retained verbatim. + * @returns unfenced lines with their original 1-based locations. + */ +export function markdownProseLines(source: string): MarkdownProseLine[] { + let fence: { marker: '`' | '~'; length: number } | undefined + const kept: MarkdownProseLine[] = [] + source.split('\n').forEach((raw, i) => { + const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1] + if (token !== undefined) { + const marker = token[0] as '`' | '~' + if (fence === undefined) { + fence = { marker, length: token.length } + } else if (marker === fence.marker && token.length >= fence.length) { + fence = undefined + } + return + } + if (fence === undefined) kept.push({ index: i + 1, raw }) + }) + return kept +} diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index fe91fe45c8..e0048d77d9 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -34,6 +34,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { markdownProseLines } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -60,33 +61,8 @@ function isLimitationsLike(headingText: string): boolean { ) } -interface Line { - index: number - raw: string -} - const ATX_HEADING = /^ {0,3}#{1,6}[ \t]+/ -/** Split a README into prose lines (fenced code dropped), keeping 1-based line numbers. */ -function proseLines(text: string): Line[] { - let fence: { marker: '`' | '~'; length: number } | undefined - const kept: Line[] = [] - text.split('\n').forEach((raw, i) => { - const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1] - if (token !== undefined) { - const marker = token[0] as '`' | '~' - if (fence === undefined) { - fence = { marker, length: token.length } - } else if (marker === fence.marker && token.length >= fence.length) { - fence = undefined - } - return - } - if (fence === undefined) kept.push({ index: i + 1, raw }) - }) - return kept -} - const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) const failures: string[] = [] @@ -106,7 +82,7 @@ for (const pkg of scannedPackages) { failures.push(`${readme}: package manifest has no sibling README with the \`${CANONICAL}\` section`) continue } - const lines = proseLines(readFileSync(resolve(root, readme), 'utf8')) + const lines = markdownProseLines(readFileSync(resolve(root, readme), 'utf8')) const headings = lines.filter(line => ATX_HEADING.test(line.raw)) const limitations = headings.filter(line => isLimitationsLike(line.raw.replace(ATX_HEADING, ''))) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 5f8d559857..b78989eddd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -11,6 +11,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { relative, resolve } from 'node:path' +import { markdownProseLines, type MarkdownProseLine } from './markdown.ts' const root = resolve(import.meta.dirname, '..') const HEADING = '## Model Experience' @@ -69,10 +70,7 @@ interface Failure { message: string } -interface Line { - index: number - raw: string -} +type Line = MarkdownProseLine interface ContextSurface { heading: Line @@ -82,26 +80,6 @@ interface ContextSurface { verbatimBlocks: number } -/** Split Markdown into prose lines, excluding fenced code that may quote the contract. */ -function proseLines(text: string): Line[] { - let fence: { marker: '`' | '~'; length: number } | undefined - const kept: Line[] = [] - text.split('\n').forEach((raw, i) => { - const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1] - if (token !== undefined) { - const marker = token[0] as '`' | '~' - if (fence === undefined) { - fence = { marker, length: token.length } - } else if (marker === fence.marker && token.length >= fence.length) { - fence = undefined - } - return - } - if (fence === undefined) kept.push({ index: i + 1, raw }) - }) - return kept -} - /** Validate H4-plus-markdown literals nested after one context surface's fields. */ function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error?: string } { let cursor = 0 @@ -192,7 +170,7 @@ for (const packageJson of packageJsons) { const text = readFileSync(abs, 'utf8') const rawLines = text.split('\n') - const lines = proseLines(text) + const lines = markdownProseLines(text) const h2Headings = lines.filter(line => H2_HEADING.test(line.raw)) const modelHeadings = h2Headings.filter(line => line.raw === HEADING) if (modelHeadings.length !== 1) {