Prechádzať zdrojové kódy

fix(i18n): resolve merge and Markdown edge cases

pku-xht 2 týždňov pred
rodič
commit
dd13c05192

+ 19 - 0
scripts/translation-links.spec.ts

@@ -27,6 +27,8 @@ function fixture(): string {
   writeFileSync(join(root, 'docs/guide.zh.md'), '# 指南\n')
   writeFileSync(join(root, 'docs/reference.md'), '# Overview\n')
   writeFileSync(join(root, 'docs/reference.zh.md'), '# 概览\n')
+  writeFileSync(join(root, 'docs/a#b.md'), '# Reserved\n')
+  writeFileSync(join(root, 'docs/a#b.zh.md'), '# 保留字符\n')
   writeFileSync(join(root, 'docs/unpaired.md'), '# Only\n')
   writeFileSync(join(root, 'docs/section/index.md'), '# Section\n')
   writeFileSync(join(root, 'docs/section/index.zh.md'), '# 章节\n')
@@ -85,6 +87,15 @@ describe('translation link locale validation', () => {
     })
   })
 
+  it('keeps URL-reserved filename bytes escaped in an encoded exact path', () => {
+    const root = fixture()
+    const input = '[保留](a%23b%2Emd?view=full#section)\n'
+    expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md'))).toEqual({
+      content: '[保留](a%23b.zh.md?view=full#section)\n',
+      rewritten: 1,
+    })
+  })
+
   it('accepts the target-locale sibling and an out-of-scope target with its own sibling', () => {
     const root = fixture()
     expect(translationLinkLocaleViolations(
@@ -190,6 +201,14 @@ describe('translation link rewriting and normalization', () => {
     ).content).toBe('[概览][ref]\n\n[ref]: <reference.zh.md#overview> "title"\n')
   })
 
+  it('uses only the first duplicate reference definition', () => {
+    const root = fixture()
+    expect(translationLinkLocaleViolations(
+      '[概览][ref]\n\n[ref]: reference.zh.md\n[ref]: reference.md\n',
+      linkContext(root, 'docs/guide.zh.md'),
+    )).toEqual([])
+  })
+
   it('does not treat an image-only definition as a document link', () => {
     const root = fixture()
     const input = '![preview][asset]\n\n[asset]: reference.zh.md#overview\n'

+ 8 - 2
scripts/translation-links.ts

@@ -149,7 +149,7 @@ function relativeExpectedPath(
   rawPath: string,
 ): string {
   const relative = posix.relative(posix.dirname(context.sourcePath), expectedPath)
-  const encoded = encodeURI(relative)
+  const encoded = relative.split('/').map(segment => encodeURIComponent(segment)).join('/')
   return rawPath.startsWith('./') && !encoded.startsWith('.') ? `./${encoded}` : encoded
 }
 
@@ -227,12 +227,18 @@ function visitDocumentLinkNodes(
   const tree = parseMarkdown(markdown)
   const switcherOffset = languageSwitcherLinkOffset(tree, markdown, skipTargets)
   const referencedIdentifiers = new Set<string>()
+  const visitedDefinitions = new Set<string>()
   visitMarkdown(tree, (node) => {
     if (node.type === 'linkReference') referencedIdentifiers.add(node.identifier)
   })
   visitMarkdown(tree, (node) => {
     if (node.type === 'link' && node.position?.start.offset === switcherOffset) return
-    if (node.type === 'link' || (node.type === 'definition' && referencedIdentifiers.has(node.identifier))) {
+    if (node.type === 'link') {
+      visitor(node)
+    } else if (node.type === 'definition'
+      && referencedIdentifiers.has(node.identifier)
+      && !visitedDefinitions.has(node.identifier)) {
+      visitedDefinitions.add(node.identifier)
       visitor(node)
     }
   })

+ 23 - 0
scripts/translation-pairing-git.ts

@@ -63,6 +63,29 @@ export function gitIndexPaths(root: string): Set<string> {
   return paths
 }
 
+/**
+ * Paths visible to a custom merge driver from the current index plus every
+ * merge head Git advertises through `GITHEAD_<oid>` environment entries.
+ *
+ * Git invokes custom drivers before it writes clean additions from the other
+ * heads into stage zero. The explicit post-conflict resolver has no GITHEAD
+ * entries and therefore uses the already-merged index alone.
+ */
+export function gitMergeInputPaths(root: string, environment: NodeJS.ProcessEnv = process.env): Set<string> {
+  const paths = gitIndexPaths(root)
+  const heads = Object.keys(environment)
+    .flatMap(key => /^GITHEAD_([0-9a-f]{40})$/.exec(key)?.[1] ?? [])
+    .sort()
+  for (const head of heads) {
+    const files = runGit(root, ['ls-tree', '-r', '--name-only', '-z', head], `listing merge-head ${head} paths`)
+      .toString('utf8')
+      .split('\0')
+      .filter(Boolean)
+    for (const file of files) paths.add(file)
+  }
+  return paths
+}
+
 /**
  * Read one path from the Git index without consulting working-tree bytes.
  *

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

@@ -491,6 +491,49 @@ describe('translation pairing merge composition', { timeout: 15_000 }, () => {
     expectMergedPair(fixture)
   })
 
+  it('sees a paired link target added by the other branch', () => {
+    const fixture = createFixture()
+    commitPair(fixture, baseSource, baseZh, 'base')
+    git(fixture, ['switch', '-c', 'current'])
+    commitPair(fixture, currentSource, currentZh, 'current guide')
+    git(fixture, ['switch', 'master'])
+    record(
+      fixture.root,
+      'docs/guide.md',
+      baseSource.replace('Beta base.', '[Reference](reference.md#overview)'),
+      baseZh.replace('乙基础。', '[参考](reference.zh.md#overview)'),
+    )
+    record(
+      fixture.root,
+      'docs/reference.md',
+      '# Reference\n\nEnglish | [中文](reference.zh.md)\n\nOverview.\n',
+      '# 参考\n\n[English](reference.md) | 中文\n\n概览。\n',
+    )
+    git(fixture, ['add', '.'])
+    git(fixture, ['commit', '-m', 'other guide and target'])
+    git(fixture, ['switch', 'current'])
+    installFixtureRuntime(fixture.root)
+    git(fixture, [
+      'config',
+      'merge.dsh-translation-pairing.driver',
+      'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
+    ])
+
+    const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
+      encoding: 'utf8',
+      env: fixture.env,
+    })
+
+    expect(merge.status, merge.stderr).toBe(0)
+    expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
+    expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toContain(
+      '[Reference](reference.md#overview)',
+    )
+    expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toContain(
+      '[参考](reference.zh.md#overview)',
+    )
+  })
+
   it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
     const fixture = createFixture()
     const records = createDivergedPair(fixture)

+ 2 - 2
scripts/translation-pairing-merge.ts

@@ -7,7 +7,7 @@ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import {
   GIT_COMMAND_MAX_BUFFER,
   gitBlobHash,
-  gitIndexPaths,
+  gitMergeInputPaths,
   readGitIndexBlob,
   runGit,
   storeGitBlob,
@@ -179,7 +179,7 @@ function assertMergedPairStructure(
   const zhText = zh.toString('utf8')
   const sourceTree = parseTranslationMarkdown(sourceText)
   const zhTree = parseTranslationMarkdown(zhText)
-  const indexFiles = gitIndexPaths(root)
+  const indexFiles = gitMergeInputPaths(root)
   const repositoryFileExists = (path: string): boolean => indexFiles.has(path)
   const sourceSwitcherTargets = languageSwitcherTargets(paths.source)
   const zhSwitcherTargets = languageSwitcherTargets(paths.zh)

+ 33 - 0
scripts/translation-pairing.spec.ts

@@ -368,6 +368,39 @@ describe('translation structural signature', () => {
     }
   })
 
+  it('compares the first duplicate reference definition that CommonMark resolves', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-'))
+    try {
+      for (const name of ['reference', 'different', 'other']) {
+        writeFileSync(join(root, `${name}.md`), `# ${name}\n`)
+        writeFileSync(join(root, `${name}.zh.md`), `# ${name} zh\n`)
+      }
+      const sourceMarkdown = '[Reference][ref]\n\n[ref]: reference.md\n[ref]: other.md\n'
+      const counterpartMarkdown = '[参考][ref]\n\n[ref]: different.zh.md\n[ref]: other.zh.md\n'
+      const source = translationStructureSignature(
+        parseTranslationMarkdown(sourceMarkdown),
+        'guide.zh.md',
+        {
+          repoRoot: root, sourcePath: 'guide.md',
+          isTranslationPairSource: fixturePairSource, markdown: sourceMarkdown,
+        },
+      )
+      const counterpart = translationStructureSignature(
+        parseTranslationMarkdown(counterpartMarkdown),
+        'guide.md',
+        {
+          repoRoot: root, sourcePath: 'guide.zh.md',
+          isTranslationPairSource: fixturePairSource, markdown: counterpartMarkdown,
+        },
+      )
+      expect(translationStructureDiff(source, counterpart)).toEqual([
+        'link target #1 diverges between the pair: "dsh-translation-target:reference.md" vs "dsh-translation-target:different.md"',
+      ])
+    } finally {
+      rmSync(root, { recursive: true, force: true })
+    }
+  })
+
   it('accepts matching list kinds, starts, and item counts', () => {
     const source = signature('3. One\n4. Two\n\n- A\n- B\n')
     const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')

+ 3 - 1
scripts/translation-pairing.ts

@@ -362,7 +362,9 @@ export function translationStructureSignature(
   const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
   const definitions = new Map<string, Extract<Nodes, { type: 'definition' }>>()
   const collectDefinitions = (node: Nodes): void => {
-    if (node.type === 'definition') definitions.set(node.identifier, node)
+    if (node.type === 'definition' && !definitions.has(node.identifier)) {
+      definitions.set(node.identifier, node)
+    }
     if ('children' in node) for (const child of node.children) collectDefinitions(child)
   }
   collectDefinitions(tree)