fix(i18n): resolve merge and Markdown edge cases

This commit is contained in:
pku-xht
2026-08-19 03:47:00 +08:00
parent 2c94ec6cd3
commit dd13c05192
7 changed files with 131 additions and 5 deletions
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)