2026-08-18 19:00:37 +08:00
|
|
|
/** Locale-aware resolution and byte-preserving rewrites for bilingual Markdown links. */
|
|
|
|
|
|
|
|
|
|
import { existsSync, statSync } from 'node:fs'
|
|
|
|
|
import { posix, resolve } from 'node:path'
|
|
|
|
|
import type { Nodes } from 'mdast'
|
2026-08-18 19:37:53 +08:00
|
|
|
import {
|
|
|
|
|
isExternalOrAbsoluteMarkdownUrl,
|
|
|
|
|
markdownDestination,
|
|
|
|
|
parseMarkdown,
|
|
|
|
|
splitMarkdownUrlTarget,
|
|
|
|
|
visitMarkdown,
|
|
|
|
|
type MarkdownDestination,
|
|
|
|
|
} from './markdown.ts'
|
2026-08-18 19:00:37 +08:00
|
|
|
|
|
|
|
|
/** Repository and source document used to resolve one relative link. */
|
|
|
|
|
export interface TranslationLinkContext {
|
|
|
|
|
/** Absolute repository root. */
|
|
|
|
|
repoRoot: string
|
|
|
|
|
/** Repository-relative Markdown source path. */
|
|
|
|
|
sourcePath: string
|
|
|
|
|
/** Selected content plane; defaults to regular files in the working tree. */
|
|
|
|
|
repositoryFileExists?: (repoPath: string) => boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One relative document link whose target uses the wrong locale sibling. */
|
|
|
|
|
export interface TranslationLinkLocaleViolation {
|
|
|
|
|
sourcePath: string
|
|
|
|
|
line: number
|
|
|
|
|
url: string
|
|
|
|
|
expectedUrl: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Result of rewriting wrong-locale relative document links. */
|
|
|
|
|
export interface TranslationLinkRewriteResult {
|
|
|
|
|
content: string
|
|
|
|
|
rewritten: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TranslationPairTarget {
|
|
|
|
|
source: string
|
|
|
|
|
zh: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ResolvedTranslationLink {
|
|
|
|
|
pair: TranslationPairTarget
|
|
|
|
|
targetPath: string
|
|
|
|
|
suffix: string
|
|
|
|
|
expectedPath: string
|
|
|
|
|
expectedUrl: string
|
|
|
|
|
kind: ResolutionKind
|
|
|
|
|
locale: 'en' | 'zh'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Replacement {
|
|
|
|
|
start: number
|
|
|
|
|
end: number
|
|
|
|
|
value: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
|
2026-08-18 19:41:52 +08:00
|
|
|
type ResolutionKind = 'exact' | 'directory-index'
|
2026-08-18 19:00:37 +08:00
|
|
|
|
|
|
|
|
function decodePath(path: string): string {
|
|
|
|
|
try {
|
|
|
|
|
return decodeURIComponent(path)
|
|
|
|
|
} catch {
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function worktreeFileExists(repoRoot: string, repoPath: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const path = resolve(repoRoot, repoPath)
|
|
|
|
|
return existsSync(path) && statSync(path).isFile()
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function repositoryFileExists(context: TranslationLinkContext, repoPath: string): boolean {
|
|
|
|
|
return context.repositoryFileExists?.(repoPath) ?? worktreeFileExists(context.repoRoot, repoPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function repositoryRelativePath(path: string): string | undefined {
|
|
|
|
|
const normalized = posix.normalize(path)
|
|
|
|
|
if (normalized === '' || normalized === '.' || normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized)) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
return normalized
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveRepositoryTarget(
|
|
|
|
|
rawPath: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
): { path: string; kind: ResolutionKind } | undefined {
|
|
|
|
|
const decoded = decodePath(rawPath)
|
|
|
|
|
const exact = repositoryRelativePath(posix.join(posix.dirname(context.sourcePath), decoded))
|
|
|
|
|
if (exact === undefined) return undefined
|
|
|
|
|
if (repositoryFileExists(context, exact)) return { path: exact, kind: 'exact' }
|
|
|
|
|
const index = repositoryRelativePath(posix.join(exact, 'index.md'))
|
|
|
|
|
if (decoded.endsWith('/') && index !== undefined && repositoryFileExists(context, index)) {
|
|
|
|
|
return { path: index, kind: 'directory-index' }
|
|
|
|
|
}
|
2026-08-18 19:41:52 +08:00
|
|
|
if (posix.extname(decoded) === '' && index !== undefined && repositoryFileExists(context, index)) {
|
|
|
|
|
return { path: index, kind: 'directory-index' }
|
2026-08-18 19:00:37 +08:00
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function translationPairTarget(targetPath: string, context: TranslationLinkContext): TranslationPairTarget | undefined {
|
|
|
|
|
const source = targetPath.endsWith('.zh.md')
|
|
|
|
|
? targetPath.replace(/\.zh\.md$/, '.md')
|
|
|
|
|
: targetPath.endsWith('.md') ? targetPath : undefined
|
|
|
|
|
if (source === undefined) return undefined
|
|
|
|
|
const zh = source.replace(/\.md$/, '.zh.md')
|
|
|
|
|
if (!repositoryFileExists(context, source) || !repositoryFileExists(context, zh)) return undefined
|
|
|
|
|
return { source, zh }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fallbackRelativePath(context: TranslationLinkContext, targetPath: string, rawPath: string): string {
|
|
|
|
|
const target = posix.relative(posix.dirname(context.sourcePath), targetPath)
|
|
|
|
|
const encoded = encodeURI(target)
|
|
|
|
|
return rawPath.startsWith('./') && !encoded.startsWith('.') ? `./${encoded}` : encoded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectedLocalePath(
|
|
|
|
|
rawPath: string,
|
|
|
|
|
kind: ResolutionKind,
|
|
|
|
|
locale: 'en' | 'zh',
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
targetPath: string,
|
|
|
|
|
): string {
|
|
|
|
|
if (kind === 'exact') {
|
|
|
|
|
if (locale === 'zh' && rawPath.endsWith('.md') && !rawPath.endsWith('.zh.md')) {
|
|
|
|
|
return rawPath.replace(/\.md$/, '.zh.md')
|
|
|
|
|
}
|
|
|
|
|
if (locale === 'en' && rawPath.endsWith('.zh.md')) return rawPath.replace(/\.zh\.md$/, '.md')
|
|
|
|
|
}
|
|
|
|
|
if (kind === 'directory-index' && locale === 'zh') {
|
|
|
|
|
return `${rawPath}${rawPath.endsWith('/') ? '' : '/'}index.zh.md`
|
|
|
|
|
}
|
|
|
|
|
return fallbackRelativePath(context, targetPath, rawPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveTranslationLink(
|
|
|
|
|
url: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
authoredUrl: string = url,
|
|
|
|
|
): ResolvedTranslationLink | undefined {
|
2026-08-18 19:37:53 +08:00
|
|
|
if (isExternalOrAbsoluteMarkdownUrl(url)) return undefined
|
|
|
|
|
const { path } = splitMarkdownUrlTarget(url)
|
|
|
|
|
const authored = splitMarkdownUrlTarget(authoredUrl)
|
2026-08-18 19:00:37 +08:00
|
|
|
if (path === '') return undefined
|
|
|
|
|
const resolved = resolveRepositoryTarget(path, context)
|
|
|
|
|
if (resolved === undefined) return undefined
|
|
|
|
|
const targetPath = resolved.path
|
|
|
|
|
const pair = translationPairTarget(targetPath, context)
|
|
|
|
|
if (pair === undefined) return undefined
|
|
|
|
|
const locale = context.sourcePath.endsWith('.zh.md') ? 'zh' : 'en'
|
|
|
|
|
const expectedPath = locale === 'zh' ? pair.zh : pair.source
|
|
|
|
|
return {
|
|
|
|
|
pair,
|
|
|
|
|
targetPath,
|
|
|
|
|
suffix: authored.suffix,
|
|
|
|
|
expectedPath,
|
|
|
|
|
expectedUrl: `${expectedLocalePath(authored.path, resolved.kind, locale, context, expectedPath)}${authored.suffix}`,
|
|
|
|
|
kind: resolved.kind,
|
|
|
|
|
locale,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasExpectedLocale(resolved: ResolvedTranslationLink): boolean {
|
2026-08-18 19:41:52 +08:00
|
|
|
return resolved.targetPath === resolved.expectedPath
|
2026-08-18 19:00:37 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:37:53 +08:00
|
|
|
function replacementFor(destination: MarkdownDestination, value: string): Replacement {
|
2026-08-18 19:00:37 +08:00
|
|
|
return { start: destination.start, end: destination.end, value }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyReplacements(markdown: string, replacements: Replacement[]): string {
|
|
|
|
|
let output = markdown
|
|
|
|
|
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
|
|
|
|
|
output = output.slice(0, replacement.start) + replacement.value + output.slice(replacement.end)
|
|
|
|
|
}
|
|
|
|
|
return output
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function visitDocumentLinkNodes(markdown: string, visitor: (node: LinkNode) => void): void {
|
|
|
|
|
const tree = parseMarkdown(markdown)
|
|
|
|
|
const linkDefinitions = new Set<string>()
|
|
|
|
|
visitMarkdown(tree, (node) => {
|
|
|
|
|
if (node.type === 'linkReference') linkDefinitions.add(node.identifier)
|
|
|
|
|
})
|
|
|
|
|
visitMarkdown(tree, (node) => {
|
|
|
|
|
if (node.type === 'link' || (node.type === 'definition' && linkDefinitions.has(node.identifier))) {
|
|
|
|
|
visitor(node)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:37:53 +08:00
|
|
|
function visitResolvedDocumentLinks(
|
|
|
|
|
markdown: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
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
|
|
|
|
|
const destination = markdownDestination(markdown, node)
|
|
|
|
|
const resolved = resolveTranslationLink(node.url, context, destination.url)
|
|
|
|
|
if (resolved !== undefined) visitor(node, destination, resolved)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:00:37 +08:00
|
|
|
/** Return one violation per wrong-locale link or link definition. */
|
|
|
|
|
export function translationLinkLocaleViolations(
|
|
|
|
|
markdown: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
skipTargets: readonly string[] = [],
|
|
|
|
|
): TranslationLinkLocaleViolation[] {
|
|
|
|
|
const violations: TranslationLinkLocaleViolation[] = []
|
2026-08-18 19:37:53 +08:00
|
|
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => {
|
|
|
|
|
if (hasExpectedLocale(resolved)) return
|
2026-08-18 19:00:37 +08:00
|
|
|
violations.push({
|
|
|
|
|
sourcePath: context.sourcePath,
|
|
|
|
|
line: node.position?.start.line ?? 0,
|
2026-08-18 19:37:53 +08:00
|
|
|
url: destination.url,
|
2026-08-18 19:00:37 +08:00
|
|
|
expectedUrl: resolved.expectedUrl,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
return violations
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Rewrite wrong-locale document links without reserializing surrounding Markdown. */
|
|
|
|
|
export function rewriteTranslationLinkLocales(
|
|
|
|
|
markdown: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
skipTargets: readonly string[] = [],
|
|
|
|
|
): TranslationLinkRewriteResult {
|
|
|
|
|
const replacements: Replacement[] = []
|
2026-08-18 19:37:53 +08:00
|
|
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
|
|
|
|
|
if (hasExpectedLocale(resolved)) return
|
|
|
|
|
replacements.push(replacementFor(destination, resolved.expectedUrl))
|
2026-08-18 19:00:37 +08:00
|
|
|
})
|
|
|
|
|
return { content: applyReplacements(markdown, replacements), rewritten: replacements.length }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Normalize only paired-document locale paths while retaining every other byte and URL suffix. */
|
|
|
|
|
export function normalizeTranslationMarkdownLinks(
|
|
|
|
|
markdown: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
skipTargets: readonly string[] = [],
|
|
|
|
|
): string {
|
|
|
|
|
const replacements: Replacement[] = []
|
2026-08-18 19:37:53 +08:00
|
|
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
|
2026-08-18 19:00:37 +08:00
|
|
|
replacements.push(replacementFor(
|
2026-08-18 19:37:53 +08:00
|
|
|
destination,
|
2026-08-18 19:00:37 +08:00
|
|
|
`dsh-translation-target:${resolved.pair.source}${resolved.suffix}`,
|
|
|
|
|
))
|
|
|
|
|
})
|
|
|
|
|
return applyReplacements(markdown, replacements)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Semantic target of one authored inline link or referenced definition. */
|
|
|
|
|
export function semanticTranslationLinkNodeTarget(
|
|
|
|
|
node: LinkNode,
|
|
|
|
|
markdown: string,
|
|
|
|
|
context: TranslationLinkContext,
|
|
|
|
|
): string {
|
2026-08-18 19:37:53 +08:00
|
|
|
const destination = markdownDestination(markdown, node)
|
|
|
|
|
const resolved = resolveTranslationLink(node.url, context, destination.url)
|
2026-08-18 19:00:37 +08:00
|
|
|
return resolved === undefined
|
2026-08-18 19:37:53 +08:00
|
|
|
? destination.url
|
2026-08-18 19:00:37 +08:00
|
|
|
: `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`
|
|
|
|
|
}
|