Explorar o código

fix(i18n): share Markdown link parsing

pku-xht hai 1 mes
pai
achega
08ec2622b0

+ 14 - 4
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(

+ 95 - 0
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<Nodes, { type: 'link' | 'image' | 'definition' }>
+
+/** 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.

+ 10 - 98
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<Nodes, { type: 'link' | 'image' | 'definition' }>
 
 /** 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 `<a id>` 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,
     })
   }

+ 7 - 7
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' },
     ))
   })

+ 38 - 129
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<Nodes, { type: 'link' | 'definition' }>
 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}`
 }