diff --git a/scripts/translation-links.spec.ts b/scripts/translation-links.spec.ts index 74a3c410f0..1f7d41fc14 100644 --- a/scripts/translation-links.spec.ts +++ b/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]: "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' diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts index 8a1a396068..f28025ab7b 100644 --- a/scripts/translation-links.ts +++ b/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() + const visitedDefinitions = new Set() 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) } }) diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index 6a34a9cce1..e004c935d7 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -63,6 +63,29 @@ export function gitIndexPaths(root: string): Set { return paths } +/** + * Paths visible to a custom merge driver from the current index plus every + * merge head Git advertises through `GITHEAD_` 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 { + 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. * diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 19ad75af7c..7293989c2e 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/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) diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts index dbf1141763..7f0f25114b 100644 --- a/scripts/translation-pairing-merge.ts +++ b/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) diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index 587b71e9b8..b861caae0d 100644 --- a/scripts/translation-pairing.spec.ts +++ b/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') diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 3f30ac4c1c..9c1e039e12 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -362,7 +362,9 @@ export function translationStructureSignature( const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] } const definitions = new Map>() 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)