fix(docs): align site with bilingual source pairs

This commit is contained in:
Yichen Jiang
2026-07-15 18:08:28 +08:00
parent e0a0b8b06d
commit 6af61c6f4e
43 changed files with 2112 additions and 353 deletions
+51 -3
View File
@@ -20,6 +20,7 @@ function fixture(): { root: string; pages: DocsPage[] } {
mkdirSync(join(root, 'packages'), { recursive: true })
writeFileSync(join(root, 'docs/a.md'), '# A\n')
writeFileSync(join(root, 'docs/b.md'), '# B\n')
writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
return {
@@ -88,6 +89,46 @@ describe('rewriteMarkdown', () => {
})).toBe(source)
})
it('replaces the destination token without changing repeated titles or escapes', () => {
const { root, pages } = fixture()
const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
expect(rewriteMarkdown(source, {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe(
'[title](./reference/b.md "b.md") '
+ '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
)
})
it('routes a pair switcher across locales while ordinary links stay in locale', () => {
const { root, pages } = fixture()
writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
const paired = pages.filter(page => page.source !== 'docs/a.md')
paired.push(
{
locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
},
{
locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
},
)
expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', {
locale: 'root',
sourcePath: 'docs/a.zh.md',
route: 'guide/a.md',
pages: paired,
repoRoot: root,
repositoryRef: 'abc123',
})).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
})
it('fails loud when a relative target is missing', () => {
const { root, pages } = fixture()
expect(() => rewriteMarkdown('[missing](missing.md)\n', {
@@ -102,14 +143,21 @@ describe('rewriteMarkdown', () => {
})
describe('docsPages locale routes', () => {
it('publishes the same canonical source at every corresponding locale route', () => {
it('publishes every route in both locales and selects paired user sources', () => {
const byRoute = new Map(docsPages.map(page => [page.route, page]))
for (const page of docsPages.filter(page => page.locale === 'root')) {
const counterpart = byRoute.get(`en/${page.route}`)
expect(counterpart, page.route).toBeDefined()
expect(counterpart?.locale).toBe('en')
expect(counterpart?.source).toBe(page.source)
expect(counterpart?.contentLocale).toBe(page.contentLocale)
if (page.source.startsWith('docs/user/')) {
expect(page.source).toMatch(/\.zh\.md$/)
expect(page.contentLocale).toBe('zh-CN')
expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
expect(counterpart?.contentLocale).toBe('en-US')
} else {
expect(counterpart?.source).toBe(page.source)
expect(counterpart?.contentLocale).toBe(page.contentLocale)
}
}
})
})
+91 -8
View File
@@ -23,6 +23,13 @@ interface Replacement {
value: string
}
interface DestinationRange {
start: number
end: number
}
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
/** Inputs for rewriting one canonical Markdown page. */
export interface RewriteMarkdownOptions {
locale: DocsLocale
@@ -44,6 +51,75 @@ function isExternalOrSiteAbsolute(url: string): boolean {
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
}
function skipWhitespace(source: string, start: number): number {
let index = start
while (/\s/.test(source[index] ?? '')) index += 1
return index
}
function labelEnd(source: string): number {
const first = source.indexOf('[')
if (first === -1) return -1
let depth = 0
for (let index = first; index < source.length; index += 1) {
const char = source[index]
if (char === '\\') {
index += 1
} else if (char === '[') {
depth += 1
} else if (char === ']') {
depth -= 1
if (depth === 0) return index
}
}
return -1
}
function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
const endOfLabel = labelEnd(rawNode)
if (endOfLabel === -1) {
throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
}
let start: number
if (type === 'definition') {
const colon = rawNode.indexOf(':', endOfLabel + 1)
if (colon === -1) {
throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, colon + 1)
} else {
if (rawNode[endOfLabel + 1] !== '(') {
throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
}
start = skipWhitespace(rawNode, endOfLabel + 2)
}
if (rawNode[start] === '<') {
for (let index = start + 1; index < rawNode.length; index += 1) {
if (rawNode[index] === '\\') index += 1
else if (rawNode[index] === '>') return { start: start + 1, end: index }
}
throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
}
let depth = 0
for (let index = start; index < rawNode.length; index += 1) {
const char = rawNode[index]
if (char === '\\') {
index += 1
} else if (char === '(') {
depth += 1
} else if (char === ')') {
if (depth === 0) return { start, end: index }
depth -= 1
} else if (/\s/.test(char ?? '') && depth === 0) {
return { start, end: index }
}
}
return { start, end: rawNode.length }
}
function splitTarget(url: string): { path: string; suffix: string } {
const boundary = url.search(/[?#]/)
if (boundary === -1) return { path: url, suffix: '' }
@@ -78,6 +154,12 @@ function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
return map
}
function counterpartSource(source: string): string {
return source.endsWith('.zh.md')
? source.replace(/\.zh\.md$/, '.md')
: source.replace(/\.md$/, '.zh.md')
}
function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
const decoded = decodePath(rawPath)
let absPath = resolve(dirname(sourceAbs), decoded)
@@ -129,13 +211,17 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
const replacements: Replacement[] = []
const rewrite = (node: Nodes & { url: string }): void => {
const rewrite = (node: RewritableNode): void => {
if (isExternalOrSiteAbsolute(node.url)) return
const { path, suffix } = splitTarget(node.url)
if (path === '') return
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
const targetPath = repoPath(absPath, options.repoRoot)
const page = published.get(targetPath)?.get(options.locale)
const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
const targetLocale: DocsLocale = isLanguageSwitcher
? options.locale === 'root' ? 'en' : 'root'
: options.locale
const page = published.get(targetPath)?.get(targetLocale)
const nextUrl = page === undefined
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
: routeTarget(options.route, page.route, suffix)
@@ -146,13 +232,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
}
const rawNode = source.slice(start, end)
const urlOffset = rawNode.lastIndexOf(node.url)
if (urlOffset === -1) {
throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`)
}
const rawDestination = destinationRange(rawNode, node.type)
replacements.push({
start: start + urlOffset,
end: start + urlOffset + node.url.length,
start: start + rawDestination.start,
end: start + rawDestination.end,
value: nextUrl,
})
}
+12
View File
@@ -11,6 +11,18 @@
"docs/development.md",
"docs/i18n/README.md",
"docs/i18n/translation-rules.md",
"docs/user/develop/basic/config.md",
"docs/user/develop/basic/index.md",
"docs/user/develop/basic/tool.md",
"docs/user/develop/framework/events.md",
"docs/user/develop/framework/index.md",
"docs/user/develop/framework/service.md",
"docs/user/develop/practice/index.md",
"docs/user/develop/practice/llm-adapter.md",
"docs/user/guide/config.md",
"docs/user/guide/index.md",
"docs/user/guide/quickstart.md",
"docs/user/index.md",
"docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
"python/README.md",