diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index 5cb9351999..640a93169a 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -6,7 +6,14 @@ import { renderInheritedPage, renderPageRegion, } from '../src/cordis-catalog.ts' -import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERVICE_PAGE } from '../../../../scripts/gen-cordis-catalog.ts' +import { + CORDIS_CATALOG_POLICY, + EVENT_SCOPE_PAGE, + localizePageRegion, + REGION_BEGIN, + REGION_END, + SERVICE_PAGE, +} from '../../../../scripts/gen-cordis-catalog.ts' const workspaceRoot = resolve(import.meta.dirname, '../../../..') @@ -29,11 +36,14 @@ describe('Typert-backed Cordis catalog', () => { CORDIS_CATALOG_POLICY, ) for (const side of [page, page.replace(/\.md$/, '.zh.md')]) { - const committed = expected(`docs/subsystems/${side}`) + const rel = `docs/subsystems/${side}` + const committed = expected(rel) const begin = committed.indexOf(REGION_BEGIN) const end = committed.indexOf(REGION_END) - expect(begin, `docs/subsystems/${side} carries the region`).toBeGreaterThanOrEqual(0) - expect(committed.slice(begin, end + REGION_END.length)).toBe(region) + expect(begin, `${rel} carries the region`).toBeGreaterThanOrEqual(0) + expect(committed.slice(begin, end + REGION_END.length)).toBe( + localizePageRegion(region, rel, workspaceRoot), + ) } } expect(projector.renderRuntimeApi(model)).toBe( diff --git a/scripts/markdown.ts b/scripts/markdown.ts index 1d40e1d8bb..f221d1c342 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -56,6 +56,101 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v } } +/** Markdown nodes whose authored destination occupies a replaceable source range. */ +export type MarkdownDestinationNode = Extract + +/** One authored Markdown destination and its absolute source offsets. */ +export interface MarkdownDestination { + start: number + end: number + url: string +} + +/** Whether a Markdown URL is external, repository-root absolute, or purely in-page. */ +export function isExternalOrAbsoluteMarkdownUrl(url: string): boolean { + return url.startsWith('#') + || url.startsWith('//') + || url.startsWith('/') + || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) +} + +/** Split one Markdown URL without normalizing its query or fragment suffix. */ +export function splitMarkdownUrlTarget(url: string): { path: string; suffix: string } { + const boundary = url.search(/[?#]/) + if (boundary === -1) return { path: url, suffix: '' } + return { path: url.slice(0, boundary), suffix: url.slice(boundary) } +} + +function skipWhitespace(source: string, start: number): number { + let index = start + while (/\s/.test(source[index] ?? '')) index += 1 + return index +} + +function labelEnd(source: string): number { + const first = source.indexOf('[') + if (first === -1) return -1 + let depth = 0 + for (let index = first; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') index += 1 + else if (char === '[') depth += 1 + else if (char === ']') { + depth -= 1 + if (depth === 0) return index + } + } + return -1 +} + +function destinationRange(rawNode: string, type: MarkdownDestinationNode['type']): { start: number; end: number } { + const endOfLabel = labelEnd(rawNode) + if (endOfLabel === -1) throw new Error(`markdown: cannot locate label end in ${JSON.stringify(rawNode)}`) + let start: number + if (type === 'definition') { + const colon = rawNode.indexOf(':', endOfLabel + 1) + if (colon === -1) throw new Error(`markdown: cannot locate definition separator in ${JSON.stringify(rawNode)}`) + start = skipWhitespace(rawNode, colon + 1) + } else { + if (rawNode[endOfLabel + 1] !== '(') { + throw new Error(`markdown: cannot locate inline destination in ${JSON.stringify(rawNode)}`) + } + start = skipWhitespace(rawNode, endOfLabel + 2) + } + if (rawNode[start] === '<') { + for (let index = start + 1; index < rawNode.length; index += 1) { + if (rawNode[index] === '\\') index += 1 + else if (rawNode[index] === '>') return { start: start + 1, end: index } + } + throw new Error(`markdown: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}`) + } + let depth = 0 + for (let index = start; index < rawNode.length; index += 1) { + const char = rawNode[index] + if (char === '\\') index += 1 + else if (char === '(') depth += 1 + else if (char === ')') { + if (depth === 0) return { start, end: index } + depth -= 1 + } else if (/\s/.test(char ?? '') && depth === 0) { + return { start, end: index } + } + } + return { start, end: rawNode.length } +} + +/** Locate one parsed destination in the original Markdown without reserializing it. */ +export function markdownDestination(source: string, node: MarkdownDestinationNode): MarkdownDestination { + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) { + throw new Error(`markdown: destination ${JSON.stringify(node.url)} has no source offsets`) + } + const range = destinationRange(source.slice(start, end), node.type) + const absolute = { start: start + range.start, end: start + range.end } + return { ...absolute, url: source.slice(absolute.start, absolute.end) } +} + /** * Extract every parsed code block with its info string, in document order. * @param source - Markdown source to scan. diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 9150780c14..582a306df3 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -14,6 +14,11 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' +import { + isExternalOrAbsoluteMarkdownUrl, + markdownDestination, + splitMarkdownUrlTarget, +} from './markdown.ts' const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness' const root = resolve(import.meta.dirname, '..') @@ -35,11 +40,6 @@ interface Replacement { value: string } -interface DestinationRange { - start: number - end: number -} - type RewritableNode = Extract /** Inputs for rewriting one canonical Markdown page. */ @@ -65,94 +65,12 @@ function repoPath(absPath: string, repoRoot: string): string { return relative(repoRoot, absPath).split(sep).join('/') } -function isExternalOrSiteAbsolute(url: string): boolean { - return url.startsWith('#') - || url.startsWith('//') - || url.startsWith('/') - || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) -} - -function skipWhitespace(source: string, start: number): number { - let index = start - while (/\s/.test(source[index] ?? '')) index += 1 - return index -} - -function labelEnd(source: string): number { - const first = source.indexOf('[') - if (first === -1) return -1 - let depth = 0 - for (let index = first; index < source.length; index += 1) { - const char = source[index] - if (char === '\\') { - index += 1 - } else if (char === '[') { - depth += 1 - } else if (char === ']') { - depth -= 1 - if (depth === 0) return index - } - } - return -1 -} - -function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange { - const endOfLabel = labelEnd(rawNode) - if (endOfLabel === -1) { - throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`) - } - - let start: number - if (type === 'definition') { - const colon = rawNode.indexOf(':', endOfLabel + 1) - if (colon === -1) { - throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`) - } - start = skipWhitespace(rawNode, colon + 1) - } else { - if (rawNode[endOfLabel + 1] !== '(') { - throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`) - } - start = skipWhitespace(rawNode, endOfLabel + 2) - } - - if (rawNode[start] === '<') { - for (let index = start + 1; index < rawNode.length; index += 1) { - if (rawNode[index] === '\\') index += 1 - else if (rawNode[index] === '>') return { start: start + 1, end: index } - } - throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`) - } - - let depth = 0 - for (let index = start; index < rawNode.length; index += 1) { - const char = rawNode[index] - if (char === '\\') { - index += 1 - } else if (char === '(') { - depth += 1 - } else if (char === ')') { - if (depth === 0) return { start, end: index } - depth -= 1 - } else if (/\s/.test(char ?? '') && depth === 0) { - return { start, end: index } - } - } - return { start, end: rawNode.length } -} - // `#fragment` suffixes pass through verbatim. Generated cordis-surface // headings carry explicit `` anchors with the GitHub slug, so those // fragments resolve on the published site too; hand-written headings rely on // VitePress's own slugger, which differs from GitHub's for punctuation-heavy // text — hand-authored cross-page fragments should prefer plain-text headings // or explicit anchors. -function splitTarget(url: string): { path: string; suffix: string } { - const boundary = url.search(/[?#]/) - if (boundary === -1) return { path: url, suffix: '' } - return { path: url.slice(0, boundary), suffix: url.slice(boundary) } -} - function decodePath(path: string): string { try { return decodeURIComponent(path) @@ -239,8 +157,8 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const replacements: Replacement[] = [] const rewrite = (node: RewritableNode): void => { - if (isExternalOrSiteAbsolute(node.url)) return - const { path, suffix } = splitTarget(node.url) + if (isExternalOrAbsoluteMarkdownUrl(node.url)) return + const { path, suffix } = splitMarkdownUrlTarget(node.url) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) @@ -257,16 +175,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) ? `${options.placeImage(absPath)}${suffix}` : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') - const start = node.position?.start.offset - const end = node.position?.end.offset - if (start === undefined || end === undefined) { - throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) - } - const rawNode = source.slice(start, end) - const rawDestination = destinationRange(rawNode, node.type) + const destination = markdownDestination(source, node) replacements.push({ - start: start + rawDestination.start, - end: start + rawDestination.end, + start: destination.start, + end: destination.end, value: nextUrl, }) } diff --git a/scripts/translation-links.spec.ts b/scripts/translation-links.spec.ts index 6946a03343..de87dc500a 100644 --- a/scripts/translation-links.spec.ts +++ b/scripts/translation-links.spec.ts @@ -1,20 +1,20 @@ /** Regression coverage for locale-aware bilingual Markdown links. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { normalizeTranslationMarkdownLinks, rewriteTranslationLinkLocales, - semanticTranslationLinkTarget, translationLinkLocaleViolations, } from './translation-links.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) removeFixtureSafely(root) }) function fixture(): string { @@ -95,11 +95,11 @@ describe('translation link locale validation', () => { '[章节](section/)\n', { repoRoot: root, sourcePath: 'docs/guide.zh.md' }, )[0]).toMatchObject({ expectedUrl: 'section/index.zh.md' }) - expect(semanticTranslationLinkTarget( - 'section/', + expect(normalizeTranslationMarkdownLinks( + '[Section](section/)\n', { repoRoot: root, sourcePath: 'docs/guide.md' }, - )).toBe(semanticTranslationLinkTarget( - 'section/index.zh.md', + )).toBe(normalizeTranslationMarkdownLinks( + '[Section](section/index.zh.md)\n', { repoRoot: root, sourcePath: 'docs/guide.zh.md' }, )) }) diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts index 3474dae224..8346e401bf 100644 --- a/scripts/translation-links.ts +++ b/scripts/translation-links.ts @@ -3,7 +3,14 @@ import { existsSync, statSync } from 'node:fs' import { posix, resolve } from 'node:path' import type { Nodes } from 'mdast' -import { parseMarkdown, visitMarkdown } from './markdown.ts' +import { + isExternalOrAbsoluteMarkdownUrl, + markdownDestination, + parseMarkdown, + splitMarkdownUrlTarget, + visitMarkdown, + type MarkdownDestination, +} from './markdown.ts' /** Repository and source document used to resolve one relative link. */ export interface TranslationLinkContext { @@ -50,32 +57,9 @@ interface Replacement { value: string } -interface DestinationRange { - start: number - end: number -} - -interface AuthoredDestination extends DestinationRange { - url: string -} - type LinkNode = Extract type ResolutionKind = 'exact' | 'extensionless' | 'directory-index' -function isExternalOrAbsolute(url: string): boolean { - return url.startsWith('#') - || url.startsWith('//') - || url.startsWith('/') - || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) -} - -/** Split a URL without normalizing its query or fragment suffix. */ -export function splitTranslationLinkTarget(url: string): { path: string; suffix: string } { - const boundary = url.search(/[?#]/) - if (boundary === -1) return { path: url, suffix: '' } - return { path: url.slice(0, boundary), suffix: url.slice(boundary) } -} - function decodePath(path: string): string { try { return decodeURIComponent(path) @@ -170,9 +154,9 @@ function resolveTranslationLink( context: TranslationLinkContext, authoredUrl: string = url, ): ResolvedTranslationLink | undefined { - if (isExternalOrAbsolute(url)) return undefined - const { path } = splitTranslationLinkTarget(url) - const authored = splitTranslationLinkTarget(authoredUrl) + if (isExternalOrAbsoluteMarkdownUrl(url)) return undefined + const { path } = splitMarkdownUrlTarget(url) + const authored = splitMarkdownUrlTarget(authoredUrl) if (path === '') return undefined const resolved = resolveRepositoryTarget(path, context) if (resolved === undefined) return undefined @@ -197,76 +181,7 @@ function hasExpectedLocale(resolved: ResolvedTranslationLink): boolean { return !(resolved.locale === 'en' && resolved.kind === 'extensionless') } -function skipWhitespace(source: string, start: number): number { - let index = start - while (/\s/.test(source[index] ?? '')) index += 1 - return index -} - -function labelEnd(source: string): number { - const first = source.indexOf('[') - if (first === -1) return -1 - let depth = 0 - for (let index = first; index < source.length; index += 1) { - const char = source[index] - if (char === '\\') index += 1 - else if (char === '[') depth += 1 - else if (char === ']') { - depth -= 1 - if (depth === 0) return index - } - } - return -1 -} - -function destinationRange(rawNode: string, type: LinkNode['type']): DestinationRange { - const endOfLabel = labelEnd(rawNode) - if (endOfLabel === -1) throw new Error(`translation-links: cannot locate label end in ${JSON.stringify(rawNode)}`) - let start: number - if (type === 'definition') { - const colon = rawNode.indexOf(':', endOfLabel + 1) - if (colon === -1) throw new Error(`translation-links: cannot locate definition separator in ${JSON.stringify(rawNode)}`) - start = skipWhitespace(rawNode, colon + 1) - } else { - if (rawNode[endOfLabel + 1] !== '(') { - throw new Error(`translation-links: cannot locate inline destination in ${JSON.stringify(rawNode)}`) - } - start = skipWhitespace(rawNode, endOfLabel + 2) - } - if (rawNode[start] === '<') { - for (let index = start + 1; index < rawNode.length; index += 1) { - if (rawNode[index] === '\\') index += 1 - else if (rawNode[index] === '>') return { start: start + 1, end: index } - } - throw new Error(`translation-links: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}`) - } - let depth = 0 - for (let index = start; index < rawNode.length; index += 1) { - const char = rawNode[index] - if (char === '\\') index += 1 - else if (char === '(') depth += 1 - else if (char === ')') { - if (depth === 0) return { start, end: index } - depth -= 1 - } else if (/\s/.test(char ?? '') && depth === 0) { - return { start, end: index } - } - } - return { start, end: rawNode.length } -} - -function authoredDestination(markdown: string, node: LinkNode): AuthoredDestination { - const start = node.position?.start.offset - const end = node.position?.end.offset - if (start === undefined || end === undefined) { - throw new Error(`translation-links: link ${JSON.stringify(node.url)} has no source offsets`) - } - const range = destinationRange(markdown.slice(start, end), node.type) - const absolute = { start: start + range.start, end: start + range.end } - return { ...absolute, url: markdown.slice(absolute.start, absolute.end) } -} - -function replacementFor(destination: AuthoredDestination, value: string): Replacement { +function replacementFor(destination: MarkdownDestination, value: string): Replacement { return { start: destination.start, end: destination.end, value } } @@ -291,23 +206,34 @@ function visitDocumentLinkNodes(markdown: string, visitor: (node: LinkNode) => v }) } +function visitResolvedDocumentLinks( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[], + visitor: (node: LinkNode, destination: MarkdownDestination, resolved: ResolvedTranslationLink) => void, +): void { + const skipped = new Set(skipTargets) + visitDocumentLinkNodes(markdown, (node) => { + if (skipped.has(node.url)) return + const destination = markdownDestination(markdown, node) + const resolved = resolveTranslationLink(node.url, context, destination.url) + if (resolved !== undefined) visitor(node, destination, resolved) + }) +} + /** Return one violation per wrong-locale link or link definition. */ export function translationLinkLocaleViolations( markdown: string, context: TranslationLinkContext, skipTargets: readonly string[] = [], ): TranslationLinkLocaleViolation[] { - const skipped = new Set(skipTargets) const violations: TranslationLinkLocaleViolation[] = [] - visitDocumentLinkNodes(markdown, (node) => { - if (skipped.has(node.url)) return - const authored = authoredDestination(markdown, node) - const resolved = resolveTranslationLink(node.url, context, authored.url) - if (resolved === undefined || hasExpectedLocale(resolved)) return + visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => { + if (hasExpectedLocale(resolved)) return violations.push({ sourcePath: context.sourcePath, line: node.position?.start.line ?? 0, - url: authored.url, + url: destination.url, expectedUrl: resolved.expectedUrl, }) }) @@ -320,14 +246,10 @@ export function rewriteTranslationLinkLocales( context: TranslationLinkContext, skipTargets: readonly string[] = [], ): TranslationLinkRewriteResult { - const skipped = new Set(skipTargets) const replacements: Replacement[] = [] - visitDocumentLinkNodes(markdown, (node) => { - if (skipped.has(node.url)) return - const authored = authoredDestination(markdown, node) - const resolved = resolveTranslationLink(node.url, context, authored.url) - if (resolved === undefined || hasExpectedLocale(resolved)) return - replacements.push(replacementFor(authored, resolved.expectedUrl)) + visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => { + if (hasExpectedLocale(resolved)) return + replacements.push(replacementFor(destination, resolved.expectedUrl)) }) return { content: applyReplacements(markdown, replacements), rewritten: replacements.length } } @@ -338,38 +260,25 @@ export function normalizeTranslationMarkdownLinks( context: TranslationLinkContext, skipTargets: readonly string[] = [], ): string { - const skipped = new Set(skipTargets) const replacements: Replacement[] = [] - visitDocumentLinkNodes(markdown, (node) => { - if (skipped.has(node.url)) return - const authored = authoredDestination(markdown, node) - const resolved = resolveTranslationLink(node.url, context, authored.url) - if (resolved === undefined) return + visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => { replacements.push(replacementFor( - authored, + destination, `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`, )) }) return applyReplacements(markdown, replacements) } -/** Semantic target used by the pair structure signature. */ -export function semanticTranslationLinkTarget(url: string, context: TranslationLinkContext): string { - const resolved = resolveTranslationLink(url, context) - return resolved === undefined - ? url - : `dsh-translation-target:${resolved.pair.source}${resolved.suffix}` -} - /** Semantic target of one authored inline link or referenced definition. */ export function semanticTranslationLinkNodeTarget( node: LinkNode, markdown: string, context: TranslationLinkContext, ): string { - const authored = authoredDestination(markdown, node) - const resolved = resolveTranslationLink(node.url, context, authored.url) + const destination = markdownDestination(markdown, node) + const resolved = resolveTranslationLink(node.url, context, destination.url) return resolved === undefined - ? authored.url + ? destination.url : `dsh-translation-target:${resolved.pair.source}${resolved.suffix}` }