diff --git a/scripts/translation-links.spec.ts b/scripts/translation-links.spec.ts index f5f6a7a611..8539b864ed 100644 --- a/scripts/translation-links.spec.ts +++ b/scripts/translation-links.spec.ts @@ -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( diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts index fec370ecac..6cea0b6a29 100644 --- a/scripts/translation-links.ts +++ b/scripts/translation-links.ts @@ -60,6 +60,41 @@ interface Replacement { type LinkNode = Extract 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 => 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() 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) diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index e5acbf74ff..6a34a9cce1 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -50,12 +50,17 @@ export interface GitIndexBlob { /** Every stage-zero path currently present in the Git index. */ export function gitIndexPaths(root: string): Set { - return new Set( - runGit(root, ['ls-files', '-z'], 'listing Git index paths') - .toString('utf8') - .split('\0') - .filter(Boolean), - ) + const paths = new Set() + 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 } /** diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 37b86da06b..8eed9a9dfc 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -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) diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts index 829451463e..243c76ec9d 100644 --- a/scripts/translation-pairing-merge.ts +++ b/scripts/translation-pairing-merge.ts @@ -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, }), ) diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index 96de451189..ed24593720 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -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', () => { diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index db3e3e10ea..e8890b773e 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -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>() 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 diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 76966dd022..ac9a0aea23 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -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(