소스 검색

fix(i18n): scope switcher and merge validation

pku-xht 1 개월 전
부모
커밋
df11a8af92

+ 21 - 1
scripts/translation-links.spec.ts

@@ -75,12 +75,32 @@ describe('translation link locale validation', () => {
   it('exempts the language switcher target explicitly', () => {
     const root = fixture()
     expect(translationLinkLocaleViolations(
-      '[English](guide.md) | 中文\n',
+      '# 指南\n\n[English](guide.md) | 中文\n',
       { repoRoot: root, sourcePath: 'docs/guide.zh.md' },
       ['guide.md'],
     )).toEqual([])
   })
 
+  it('does not exempt an ordinary body link to the counterpart', () => {
+    const root = fixture()
+    const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n'
+    expect(translationLinkLocaleViolations(
+      markdown,
+      { repoRoot: root, sourcePath: 'docs/guide.zh.md' },
+      ['guide.md'],
+    )).toEqual([{
+      sourcePath: 'docs/guide.zh.md',
+      line: 5,
+      url: 'guide.md',
+      expectedUrl: 'guide.zh.md',
+    }])
+    expect(rewriteTranslationLinkLocales(
+      markdown,
+      { repoRoot: root, sourcePath: 'docs/guide.zh.md' },
+      ['guide.md'],
+    ).content).toBe('# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.zh.md)\n')
+  })
+
   it('resolves a directory alias to its paired index page', () => {
     const root = fixture()
     expect(translationLinkLocaleViolations(

+ 43 - 4
scripts/translation-links.ts

@@ -60,6 +60,41 @@ interface Replacement {
 type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
 type ResolutionKind = 'exact' | 'directory-index'
 
+/** Offset of the one top-level switcher link immediately following the H1. */
+export function languageSwitcherLinkOffset(
+  tree: Nodes,
+  markdown: string,
+  acceptedTargets: string | readonly string[],
+): number | undefined {
+  if (tree.type !== 'root') return undefined
+  const accepted = new Set(typeof acceptedTargets === 'string' ? [acceptedTargets] : acceptedTargets)
+  const headingIndex = tree.children.findIndex(node => node.type === 'heading' && node.depth === 1)
+  if (headingIndex < 0) return undefined
+  for (const node of tree.children.slice(headingIndex + 1)) {
+    if (node.type === 'heading') return undefined
+    if (node.type !== 'paragraph' || node.position === undefined) continue
+    const start = node.position.start.offset
+    const end = node.position.end.offset
+    if (start === undefined || end === undefined) continue
+    const authored = markdown.slice(start, end)
+    if (!/^(?:English \| \[中文\]\([^\n]+\)|\[English\]\([^\n]+\) \| 中文)$/.test(authored)) continue
+    const links = node.children.filter((child): child is Extract<Nodes, { type: 'link' }> => child.type === 'link')
+    if (links.length === 1 && accepted.has(links[0]?.url ?? '')) {
+      return links[0]?.position?.start.offset
+    }
+  }
+  return undefined
+}
+
+/** Whether the tree carries its canonical top-level language switcher. */
+export function hasLanguageSwitcher(
+  tree: Nodes,
+  markdown: string,
+  acceptedTargets: string | readonly string[],
+): boolean {
+  return languageSwitcherLinkOffset(tree, markdown, acceptedTargets) !== undefined
+}
+
 function decodePath(path: string): string {
   try {
     return decodeURIComponent(path)
@@ -185,13 +220,19 @@ function applyReplacements(markdown: string, replacements: Replacement[]): strin
   return output
 }
 
-function visitDocumentLinkNodes(markdown: string, visitor: (node: LinkNode) => void): void {
+function visitDocumentLinkNodes(
+  markdown: string,
+  skipTargets: readonly string[],
+  visitor: (node: LinkNode) => void,
+): void {
   const tree = parseMarkdown(markdown)
+  const switcherOffset = languageSwitcherLinkOffset(tree, markdown, skipTargets)
   const linkDefinitions = new Set<string>()
   visitMarkdown(tree, (node) => {
     if (node.type === 'linkReference') linkDefinitions.add(node.identifier)
   })
   visitMarkdown(tree, (node) => {
+    if (node.type === 'link' && node.position?.start.offset === switcherOffset) return
     if (node.type === 'link' || (node.type === 'definition' && linkDefinitions.has(node.identifier))) {
       visitor(node)
     }
@@ -204,9 +245,7 @@ function visitResolvedDocumentLinks(
   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
+  visitDocumentLinkNodes(markdown, skipTargets, (node) => {
     const destination = markdownDestination(markdown, node)
     const resolved = resolveTranslationLink(node.url, context, destination.url)
     if (resolved !== undefined) visitor(node, destination, resolved)

+ 11 - 6
scripts/translation-pairing-git.ts

@@ -50,12 +50,17 @@ export interface GitIndexBlob {
 
 /** Every stage-zero path currently present in the Git index. */
 export function gitIndexPaths(root: string): Set<string> {
-  return new Set(
-    runGit(root, ['ls-files', '-z'], 'listing Git index paths')
-      .toString('utf8')
-      .split('\0')
-      .filter(Boolean),
-  )
+  const paths = new Set<string>()
+  const entries = runGit(root, ['ls-files', '--stage', '-z'], 'listing Git index paths')
+    .toString('utf8')
+    .split('\0')
+    .filter(Boolean)
+  for (const entry of entries) {
+    const match = /^\d+ [0-9a-f]+ ([0-3])\t([\s\S]+)$/.exec(entry)
+    if (!match?.[1] || match[2] === undefined) throw new Error('git ls-files --stage returned a malformed entry')
+    if (match[1] === '0') paths.add(match[2])
+  }
+  return paths
 }
 
 /**

+ 5 - 0
scripts/translation-pairing-merge.spec.ts

@@ -6,6 +6,7 @@ import {
   mkdtempSync,
   mkdirSync,
   readFileSync,
+  rmSync,
   symlinkSync,
   writeFileSync,
 } from 'node:fs'
@@ -275,11 +276,14 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => {
     const fixture = createFixture(false)
     write(fixture.root, 'docs/reference.md', '# Overview\n')
     write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
+    git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
     const source = baseSource.replace('Alpha base.', '[Reference](reference.md#overview)')
     const zh = baseZh.replace('甲基础。', '[参考](reference.zh.md#overview)')
     const ancestor = record(fixture.root, 'docs/guide.md', source, zh)
     const current = record(fixture.root, 'docs/guide.md', source, zh)
     const other = record(fixture.root, 'docs/guide.md', source, zh)
+    rmSync(join(fixture.root, 'docs/reference.md'))
+    rmSync(join(fixture.root, 'docs/reference.zh.md'))
 
     expect(mergeTranslationPairingRecords(
       fixture.root,
@@ -294,6 +298,7 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => {
     const fixture = createFixture(false)
     write(fixture.root, 'docs/reference.md', '# Overview\n')
     write(fixture.root, 'docs/reference.zh.md', '# 概览\n')
+    git(fixture, ['add', 'docs/reference.md', 'docs/reference.zh.md'])
     const source = baseSource.replace('Alpha base.', '[Reference](reference.md)')
     const zh = baseZh.replace('甲基础。', '[参考](reference.md)')
     const ancestor = record(fixture.root, 'docs/guide.md', source, zh)

+ 14 - 4
scripts/translation-pairing-merge.ts

@@ -7,6 +7,7 @@ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import {
   GIT_COMMAND_MAX_BUFFER,
   gitBlobHash,
+  gitIndexPaths,
   readGitIndexBlob,
   runGit,
   storeGitBlob,
@@ -14,13 +15,15 @@ import {
 import {
   isTranslationScopeFile,
   languageSwitcherTargets,
-  linksTo,
   parseTranslationMarkdown,
   requiresSourceLanguageSwitcher,
   translationStructureDiff,
   translationStructureSignature,
 } from './translation-pairing.ts'
-import { translationLinkLocaleViolations } from './translation-links.ts'
+import {
+  hasLanguageSwitcher,
+  translationLinkLocaleViolations,
+} from './translation-links.ts'
 import {
   parseTranslationPairingRecord,
   renderTranslationPairingRecord,
@@ -168,22 +171,27 @@ function assertMergedPairStructure(root: string, paths: TranslationPairPaths, so
   const zhText = zh.toString('utf8')
   const sourceTree = parseTranslationMarkdown(sourceText)
   const zhTree = parseTranslationMarkdown(zhText)
+  const indexFiles = gitIndexPaths(root)
+  const repositoryFileExists = (path: string): boolean => indexFiles.has(path)
   const sourceSwitcherTargets = languageSwitcherTargets(paths.source)
   const zhSwitcherTargets = languageSwitcherTargets(paths.zh)
-  if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, zhSwitcherTargets)) {
+  if (requiresSourceLanguageSwitcher(paths.source)
+    && !hasLanguageSwitcher(sourceTree, sourceText, zhSwitcherTargets)) {
     throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
   }
-  if (!linksTo(zhTree, sourceSwitcherTargets)) {
+  if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) {
     throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
   }
   const localeViolations = [
     ...translationLinkLocaleViolations(sourceText, {
       repoRoot: root,
       sourcePath: paths.source,
+      repositoryFileExists,
     }, zhSwitcherTargets),
     ...translationLinkLocaleViolations(zhText, {
       repoRoot: root,
       sourcePath: paths.zh,
+      repositoryFileExists,
     }, sourceSwitcherTargets),
   ]
   if (localeViolations.length > 0) {
@@ -195,11 +203,13 @@ function assertMergedPairStructure(root: string, paths: TranslationPairPaths, so
     translationStructureSignature(sourceTree, zhSwitcherTargets, {
       repoRoot: root,
       sourcePath: paths.source,
+      repositoryFileExists,
       markdown: sourceText,
     }),
     translationStructureSignature(zhTree, sourceSwitcherTargets, {
       repoRoot: root,
       sourcePath: paths.zh,
+      repositoryFileExists,
       markdown: zhText,
     }),
   )

+ 19 - 4
scripts/translation-pairing.spec.ts

@@ -185,9 +185,8 @@ describe('translation pairing switchers', () => {
 
   it('accepts only the canonical public URL for an absolute switcher', () => {
     const targets = languageSwitcherTargets('python/sdk/README.zh.md')
-    const canonical = parseTranslationMarkdown(
-      '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)',
-    )
+    const canonicalMarkdown = '# README\n\nEnglish | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)\n'
+    const canonical = parseTranslationMarkdown(canonicalMarkdown)
     const wrongPath = parseTranslationMarkdown(
       '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)',
     )
@@ -196,10 +195,26 @@ describe('translation pairing switchers', () => {
     expect(translationStructureSignature(canonical, targets, {
       repoRoot: process.cwd(),
       sourcePath: 'python/sdk/README.md',
-      markdown: '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)',
+      markdown: canonicalMarkdown,
     }).links).toEqual([])
     expect(linksTo(wrongPath, targets)).toBe(false)
   })
+
+  it('excludes only the header switcher from the structural links', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-translation-switcher-'))
+    try {
+      writeFileSync(join(root, 'guide.md'), '# Guide\n')
+      writeFileSync(join(root, 'guide.zh.md'), '# 指南\n')
+      const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n'
+      expect(translationStructureSignature(
+        parseTranslationMarkdown(markdown),
+        languageSwitcherTargets('guide.md'),
+        { repoRoot: root, sourcePath: 'guide.zh.md', markdown },
+      ).links).toEqual(['dsh-translation-target:guide.md'])
+    } finally {
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
 })
 
 describe('translation pairing records', () => {

+ 4 - 5
scripts/translation-pairing.ts

@@ -13,6 +13,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
 import type { Nodes } from 'mdast'
 import {
+  languageSwitcherLinkOffset,
   semanticTranslationLinkNodeTarget,
   type TranslationLinkContext,
 } from './translation-links.ts'
@@ -353,9 +354,7 @@ export function translationStructureSignature(
   switcherTargets: string | readonly string[],
   linkContext: TranslationLinkContext & { markdown: string },
 ): TranslationStructureSignature {
-  const acceptedSwitchers = new Set(
-    typeof switcherTargets === 'string' ? [switcherTargets] : switcherTargets,
-  )
+  const switcherOffset = languageSwitcherLinkOffset(tree, linkContext.markdown, switcherTargets)
   const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
   const definitions = new Map<string, Extract<Nodes, { type: 'definition' }>>()
   const collectDefinitions = (node: Nodes): void => {
@@ -383,13 +382,13 @@ export function translationStructureSignature(
           : `bullet:items=${node.children.length}`)
         break
       case 'link':
-        if (!acceptedSwitchers.has(node.url)) {
+        if (node.position?.start.offset !== switcherOffset) {
           sig.links.push(linkTarget(node))
         }
         break
       case 'linkReference': {
         const definition = definitions.get(node.identifier)
-        if (definition !== undefined && !acceptedSwitchers.has(definition.url)) {
+        if (definition !== undefined) {
           sig.links.push(linkTarget(definition))
         }
         break

+ 3 - 3
scripts/verify-translation-pairing.ts

@@ -25,7 +25,6 @@ import {
 } from './translation-pairing-record.ts'
 import {
   languageSwitcherTargets,
-  linksTo,
   parseTranslationMarkdown,
   parseTranslationPairingCliArgs,
   parseTranslationPairingManifest,
@@ -37,6 +36,7 @@ import {
   translationStructureSignature,
 } from './translation-pairing.ts'
 import {
+  hasLanguageSwitcher,
   normalizeTranslationMarkdownLinks,
   translationLinkLocaleViolations,
 } from './translation-links.ts'
@@ -294,10 +294,10 @@ for (const source of [...pairAnchors].sort()) {
 
   const sourceTree = parseTranslationMarkdown(sourceText)
   const zhTree = parseTranslationMarkdown(zhText)
-  if (!linksTo(zhTree, sourceSwitcherTargets)) {
+  if (!hasLanguageSwitcher(zhTree, zhText, sourceSwitcherTargets)) {
     errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
   }
-  if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) {
+  if (requiresSourceLanguageSwitcher(source) && !hasLanguageSwitcher(sourceTree, sourceText, zhSwitcherTargets)) {
     errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
   }
   for (const divergence of translationStructureDiff(