| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- /** 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'
- import {
- isExternalOrAbsoluteMarkdownUrl,
- markdownDestination,
- parseMarkdown,
- splitMarkdownUrlTarget,
- visitMarkdown,
- type MarkdownDestination,
- } from './markdown.ts'
- /** 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
- /** Whether an English Markdown path belongs to the active bilingual corpus. */
- isTranslationPairSource: (sourcePath: string) => boolean
- /** 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
- locale: 'en' | 'zh'
- }
- interface Replacement {
- start: number
- end: number
- value: string
- }
- type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
- /** 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)
- } 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,
- ): string | undefined {
- const decoded = decodePath(rawPath)
- const exact = repositoryRelativePath(posix.join(posix.dirname(context.sourcePath), decoded))
- if (exact === undefined) return undefined
- return repositoryFileExists(context, exact) ? exact : 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 || !context.isTranslationPairSource(source)) return undefined
- const zh = source.replace(/\.md$/, '.zh.md')
- return { source, zh }
- }
- function encodePathSegment(segment: string): string {
- return encodeURIComponent(segment).replace(/[!'()*]/g, character => (
- `%${character.charCodeAt(0).toString(16).toUpperCase()}`
- ))
- }
- function relativeExpectedPath(
- context: TranslationLinkContext,
- expectedPath: string,
- rawPath: string,
- ): string {
- const relative = posix.relative(posix.dirname(context.sourcePath), expectedPath)
- const encoded = relative.split('/').map(encodePathSegment).join('/')
- return rawPath.startsWith('./') && !encoded.startsWith('.') ? `./${encoded}` : encoded
- }
- function expectedLocalePath(
- rawPath: string,
- locale: 'en' | 'zh',
- context: TranslationLinkContext,
- expectedPath: string,
- ): string {
- 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')
- return relativeExpectedPath(context, expectedPath, rawPath)
- }
- function resolveTranslationLink(
- url: string,
- context: TranslationLinkContext,
- authoredUrl: string,
- ): ResolvedTranslationLink | undefined {
- if (isExternalOrAbsoluteMarkdownUrl(url)) return undefined
- const { path } = splitMarkdownUrlTarget(url)
- const authored = splitMarkdownUrlTarget(authoredUrl)
- if (path === '') return undefined
- const targetPath = resolveRepositoryTarget(path, context)
- if (targetPath === undefined) return undefined
- 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, locale, context, expectedPath)}${authored.suffix}`,
- locale,
- }
- }
- function hasExpectedLocale(resolved: ResolvedTranslationLink): boolean {
- return resolved.targetPath === resolved.expectedPath
- }
- function replacementFor(destination: MarkdownDestination, value: string): Replacement {
- return { start: destination.start, end: destination.end, value }
- }
- function authoredExternalTarget(markdown: string, node: LinkNode): string {
- const start = node.position?.start.offset
- const end = node.position?.end.offset
- if (start === undefined || end === undefined) {
- throw new Error(`translation-links: external link ${JSON.stringify(node.url)} has no source offsets`)
- }
- const raw = markdown.slice(start, end)
- if (node.type === 'definition' || raw.startsWith('[')) return markdownDestination(markdown, node).url
- if (raw.startsWith('<') && raw.endsWith('>')) return raw.slice(1, -1)
- return raw
- }
- 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,
- skipTargets: readonly string[],
- visitor: (node: LinkNode) => void,
- ): void {
- 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') {
- visitor(node)
- } else if (node.type === 'definition'
- && referencedIdentifiers.has(node.identifier)
- && !visitedDefinitions.has(node.identifier)) {
- visitedDefinitions.add(node.identifier)
- visitor(node)
- }
- })
- }
- function visitResolvedDocumentLinks(
- markdown: string,
- context: TranslationLinkContext,
- skipTargets: readonly string[],
- visitor: (node: LinkNode, destination: MarkdownDestination, resolved: ResolvedTranslationLink) => void,
- ): void {
- visitDocumentLinkNodes(markdown, skipTargets, (node) => {
- if (isExternalOrAbsoluteMarkdownUrl(node.url)) return
- const destination = markdownDestination(markdown, node)
- const resolved = resolveTranslationLink(node.url, context, destination.url)
- if (resolved !== undefined) visitor(node, destination, resolved)
- })
- }
- /** Return one violation per wrong-locale link or link definition. */
- export function translationLinkLocaleViolations(
- markdown: string,
- context: TranslationLinkContext,
- skipTargets: readonly string[] = [],
- ): TranslationLinkLocaleViolation[] {
- const violations: TranslationLinkLocaleViolation[] = []
- visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => {
- if (hasExpectedLocale(resolved)) return
- violations.push({
- sourcePath: context.sourcePath,
- line: node.position?.start.line ?? 0,
- url: destination.url,
- 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[] = []
- visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
- if (hasExpectedLocale(resolved)) return
- replacements.push(replacementFor(destination, resolved.expectedUrl))
- })
- 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[] = []
- visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
- replacements.push(replacementFor(
- destination,
- `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 {
- if (isExternalOrAbsoluteMarkdownUrl(node.url)) return authoredExternalTarget(markdown, node)
- const destination = markdownDestination(markdown, node)
- const resolved = resolveTranslationLink(node.url, context, destination.url)
- return resolved === undefined
- ? destination.url
- : `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`
- }
|