fix(i18n): scope switcher and merge validation
This commit is contained in:
parent
205159049f
commit
df11a8af92
8 changed files with 120 additions and 27 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue