fix(i18n): scope switcher and merge validation

This commit is contained in:
pku-xht 2026-08-18 20:05:01 +08:00
parent 205159049f
commit df11a8af92
8 changed files with 120 additions and 27 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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
}
/**

View file

@ -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)

View file

@ -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,
}),
)

View file

@ -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', () => {

View file

@ -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

View file

@ -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(