project-doc-site.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. /**
  2. * Build-time projection from canonical repository Markdown into VitePress.
  3. *
  4. * The generated tree is disposable: sources stay in their owning `docs/`
  5. * tier, while this adapter rewrites cross-source links for the public site.
  6. */
  7. import {
  8. copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
  9. } from 'node:fs'
  10. import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'
  11. import { fromMarkdown } from 'mdast-util-from-markdown'
  12. import { gfmFromMarkdown } from 'mdast-util-gfm'
  13. import { gfm } from 'micromark-extension-gfm'
  14. import type { Nodes } from 'mdast'
  15. import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
  16. const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
  17. const root = resolve(import.meta.dirname, '..')
  18. const generatedRoot = resolve(root, 'website/.generated')
  19. /**
  20. * Resolve the public repository ref used by projected source links.
  21. *
  22. * @param environment Build environment containing an optional explicit public ref.
  23. * @returns The configured public ref, or `master`.
  24. */
  25. export function resolveRepositoryRef(environment: NodeJS.ProcessEnv): string {
  26. return environment.DOCS_REPOSITORY_REF ?? 'master'
  27. }
  28. interface Replacement {
  29. start: number
  30. end: number
  31. value: string
  32. }
  33. interface DestinationRange {
  34. start: number
  35. end: number
  36. }
  37. type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
  38. /** Inputs for rewriting one canonical Markdown page. */
  39. export interface RewriteMarkdownOptions {
  40. locale: DocsLocale
  41. sourcePath: string
  42. route: string
  43. pages: DocsPage[]
  44. repoRoot: string
  45. repositoryRef: string
  46. /**
  47. * Place one referenced image beside the projected page and return the URL to
  48. * reach it from that page. A GitHub raw URL cannot serve this repository —
  49. * `raw.githubusercontent.com` answers 404 for a private one, and no reader of
  50. * the site is authenticated to it — so an image travels into the generated
  51. * tree and Vite bundles it like any other site asset. Omitted by callers that
  52. * only rewrite text, which then leave images pointing at the repository.
  53. */
  54. placeImage?: (absPath: string) => string
  55. }
  56. function repoPath(absPath: string, repoRoot: string): string {
  57. return relative(repoRoot, absPath).split(sep).join('/')
  58. }
  59. function isExternalOrSiteAbsolute(url: string): boolean {
  60. return url.startsWith('#')
  61. || url.startsWith('//')
  62. || url.startsWith('/')
  63. || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
  64. }
  65. function skipWhitespace(source: string, start: number): number {
  66. let index = start
  67. while (/\s/.test(source[index] ?? '')) index += 1
  68. return index
  69. }
  70. function labelEnd(source: string): number {
  71. const first = source.indexOf('[')
  72. if (first === -1) return -1
  73. let depth = 0
  74. for (let index = first; index < source.length; index += 1) {
  75. const char = source[index]
  76. if (char === '\\') {
  77. index += 1
  78. } else if (char === '[') {
  79. depth += 1
  80. } else if (char === ']') {
  81. depth -= 1
  82. if (depth === 0) return index
  83. }
  84. }
  85. return -1
  86. }
  87. function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
  88. const endOfLabel = labelEnd(rawNode)
  89. if (endOfLabel === -1) {
  90. throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
  91. }
  92. let start: number
  93. if (type === 'definition') {
  94. const colon = rawNode.indexOf(':', endOfLabel + 1)
  95. if (colon === -1) {
  96. throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
  97. }
  98. start = skipWhitespace(rawNode, colon + 1)
  99. } else {
  100. if (rawNode[endOfLabel + 1] !== '(') {
  101. throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
  102. }
  103. start = skipWhitespace(rawNode, endOfLabel + 2)
  104. }
  105. if (rawNode[start] === '<') {
  106. for (let index = start + 1; index < rawNode.length; index += 1) {
  107. if (rawNode[index] === '\\') index += 1
  108. else if (rawNode[index] === '>') return { start: start + 1, end: index }
  109. }
  110. throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
  111. }
  112. let depth = 0
  113. for (let index = start; index < rawNode.length; index += 1) {
  114. const char = rawNode[index]
  115. if (char === '\\') {
  116. index += 1
  117. } else if (char === '(') {
  118. depth += 1
  119. } else if (char === ')') {
  120. if (depth === 0) return { start, end: index }
  121. depth -= 1
  122. } else if (/\s/.test(char ?? '') && depth === 0) {
  123. return { start, end: index }
  124. }
  125. }
  126. return { start, end: rawNode.length }
  127. }
  128. // `#fragment` suffixes pass through verbatim. Generated cordis-surface
  129. // headings carry explicit `<a id>` anchors with the GitHub slug, so those
  130. // fragments resolve on the published site too; hand-written headings rely on
  131. // VitePress's own slugger, which differs from GitHub's for punctuation-heavy
  132. // text — hand-authored cross-page fragments should prefer plain-text headings
  133. // or explicit anchors.
  134. function splitTarget(url: string): { path: string; suffix: string } {
  135. const boundary = url.search(/[?#]/)
  136. if (boundary === -1) return { path: url, suffix: '' }
  137. return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
  138. }
  139. function decodePath(path: string): string {
  140. try {
  141. return decodeURIComponent(path)
  142. } catch {
  143. throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
  144. }
  145. }
  146. function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
  147. const target = posix.relative(posix.dirname(fromRoute), toRoute)
  148. return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
  149. }
  150. function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
  151. const map = new Map<string, Map<DocsLocale, DocsPage>>()
  152. for (const page of pages) {
  153. for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
  154. const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
  155. if (localized.has(page.locale)) {
  156. throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
  157. }
  158. localized.set(page.locale, page)
  159. map.set(source, localized)
  160. }
  161. }
  162. return map
  163. }
  164. function counterpartSource(source: string): string {
  165. return source.endsWith('.zh.md')
  166. ? source.replace(/\.zh\.md$/, '.md')
  167. : source.replace(/\.md$/, '.zh.md')
  168. }
  169. function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
  170. const decoded = decodePath(rawPath)
  171. let absPath = resolve(dirname(sourceAbs), decoded)
  172. if (existsSync(absPath)) return { absPath }
  173. const lineMatch = decoded.match(/:(\d+)$/)
  174. if (lineMatch !== null) {
  175. const lineText = lineMatch[1]
  176. if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
  177. absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
  178. if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
  179. }
  180. if (extname(decoded) === '') {
  181. const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
  182. if (existsSync(markdown)) return { absPath: markdown }
  183. const index = resolve(dirname(sourceAbs), decoded, 'index.md')
  184. if (existsSync(index)) return { absPath: index }
  185. }
  186. throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
  187. }
  188. function githubTarget(
  189. absPath: string,
  190. line: number | undefined,
  191. suffix: string,
  192. repositoryRef: string,
  193. repoRoot: string,
  194. image: boolean,
  195. ): string {
  196. const path = repoPath(absPath, repoRoot)
  197. if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}`
  198. const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
  199. const lineSuffix = line === undefined ? suffix : `#L${line}`
  200. return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
  201. }
  202. /**
  203. * Rewrite repository-relative links without reserializing Markdown.
  204. *
  205. * @param source Markdown text from the canonical file.
  206. * @param options Source, route, manifest, and repository context.
  207. * @returns Markdown whose published links resolve inside the site or to GitHub.
  208. */
  209. export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
  210. const sourceAbs = resolve(options.repoRoot, options.sourcePath)
  211. const published = sourceMap(options.pages)
  212. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  213. const replacements: Replacement[] = []
  214. const rewrite = (node: RewritableNode): void => {
  215. if (isExternalOrSiteAbsolute(node.url)) return
  216. const { path, suffix } = splitTarget(node.url)
  217. if (path === '') return
  218. const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
  219. const targetPath = repoPath(absPath, options.repoRoot)
  220. const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
  221. const targetLocale: DocsLocale = isLanguageSwitcher
  222. ? options.locale === 'root' ? 'en' : 'root'
  223. : options.locale
  224. const page = published.get(targetPath)?.get(targetLocale)
  225. const nextUrl = page !== undefined
  226. ? routeTarget(options.route, page.route, suffix)
  227. : node.type === 'image' && options.placeImage !== undefined
  228. // The suffix rides along exactly as the GitHub branch keeps it: an SVG
  229. // view fragment or a Vite query changes what the reference means.
  230. ? `${options.placeImage(absPath)}${suffix}`
  231. : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
  232. const start = node.position?.start.offset
  233. const end = node.position?.end.offset
  234. if (start === undefined || end === undefined) {
  235. throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
  236. }
  237. const rawNode = source.slice(start, end)
  238. const rawDestination = destinationRange(rawNode, node.type)
  239. replacements.push({
  240. start: start + rawDestination.start,
  241. end: start + rawDestination.end,
  242. value: nextUrl,
  243. })
  244. }
  245. const visit = (node: Nodes): void => {
  246. if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
  247. if ('children' in node) {
  248. for (const child of node.children) visit(child)
  249. }
  250. }
  251. visit(tree)
  252. let projected = source
  253. for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
  254. projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
  255. }
  256. return projected
  257. }
  258. /**
  259. * Record the canonical edit target in VitePress frontmatter.
  260. *
  261. * @param markdown Projected Markdown content.
  262. * @param page Publication manifest entry for the content.
  263. * @returns Markdown with projection-owned frontmatter fields.
  264. */
  265. export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
  266. const fields = [
  267. `editSource: ${JSON.stringify(page.source)}`,
  268. ...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
  269. ].join('\n')
  270. if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
  271. return `---\n${fields}\n---\n\n${markdown}`
  272. }
  273. /** The switcher line a canonical page carries so its GitHub reader can reach the other language. */
  274. const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\([^)]*\)|\[English\]\([^)]*\) \| 中文)$/
  275. /** The repository badge a canonical page carries for its GitHub reader. */
  276. const REPOSITORY_BADGE = /^\[!\[[^\]]*\]\(https:\/\/img\.shields\.io\/[^)]*\)\]\([^)]*\)$/
  277. /**
  278. * Drop the lines that address a canonical page's GitHub reader.
  279. *
  280. * The site carries a locale switcher in its navigation bar and links the
  281. * repository from every page, so projecting these lines would repeat both — the
  282. * switcher as the first element under each heading.
  283. *
  284. * @param markdown Rewritten canonical Markdown content.
  285. * @returns The content without the switcher line or the repository badge.
  286. */
  287. function withoutRepositoryChrome(markdown: string): string {
  288. const lines = markdown.split('\n')
  289. const switcher = lines.findIndex(line => LANGUAGE_SWITCHER.test(line))
  290. // Only the switcher introducing the page qualifies; further down the same
  291. // text is prose or a sample rather than the page's own header.
  292. if (switcher !== -1 && switcher < 8) {
  293. lines.splice(switcher, lines[switcher + 1] === '' ? 2 : 1)
  294. }
  295. const badge = lines.findLastIndex(line => REPOSITORY_BADGE.test(line))
  296. if (badge !== -1) {
  297. lines.splice(lines[badge - 1] === '' ? badge - 1 : badge, lines[badge - 1] === '' ? 2 : 1)
  298. }
  299. return lines.join('\n')
  300. }
  301. /**
  302. * Select the Markdown rendered for one published page.
  303. *
  304. * @param markdown Rewritten canonical Markdown content.
  305. * @param page Publication manifest entry for the content.
  306. * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
  307. */
  308. export function projectedPageContent(markdown: string, page: DocsPage): string {
  309. if (page.sidebar !== null) return withoutRepositoryChrome(markdown)
  310. if (!markdown.startsWith('---\n')) {
  311. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
  312. }
  313. const closingDelimiter = '\n---\n'
  314. const closing = markdown.indexOf(closingDelimiter, 4)
  315. if (closing === -1) {
  316. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
  317. }
  318. return markdown.slice(0, closing + closingDelimiter.length)
  319. }
  320. /**
  321. * The repository file one image reference resolves to, or `undefined` when the
  322. * target is not a local file this build may publish.
  323. * @param absPath - resolved image target.
  324. * @param repoRoot - repository root every published image must stay inside.
  325. * @returns the file's real path, or `undefined` when it must not be copied.
  326. *
  327. * Only a regular file whose real path stays inside the repository qualifies.
  328. * Publication copies the bytes into the site, so a reference escaping the
  329. * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree —
  330. * would put a build-machine file on the site; `existsSync` alone, which is all
  331. * link resolution needs, does not answer that.
  332. */
  333. export function publishableImage(absPath: string, repoRoot: string): string | undefined {
  334. const real = realpathSync(absPath)
  335. const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`)
  336. return inside && statSync(real).isFile() ? real : undefined
  337. }
  338. /** Every local image a published page references, resolved to its repository file. */
  339. function referencedImages(): string[] {
  340. const found = new Set<string>()
  341. for (const page of docsPages) {
  342. const sourceAbs = resolve(root, page.source)
  343. if (!existsSync(sourceAbs)) continue
  344. rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), {
  345. sourcePath: page.source,
  346. locale: page.locale,
  347. route: page.route,
  348. pages: docsPages,
  349. repoRoot: root,
  350. repositoryRef: 'master',
  351. placeImage: (absPath) => {
  352. const real = publishableImage(absPath, root)
  353. if (real !== undefined) found.add(real)
  354. return ''
  355. },
  356. })
  357. }
  358. return [...found]
  359. }
  360. /**
  361. * Files watched by the local VitePress dev server: every canonical Markdown
  362. * source, plus the images they publish. Without the images, replacing a
  363. * screenshot leaves the previous copy in the generated tree until something
  364. * touches the Markdown beside it.
  365. */
  366. export function docsSourceFiles(): string[] {
  367. return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])]
  368. }
  369. /** Rebuild the disposable VitePress source tree from the publication manifest. */
  370. export function projectDocs(): void {
  371. const routes = new Set<string>()
  372. /** Projected path to the repository file that claimed it, pages and images alike. */
  373. const claimed = new Map<string, string>()
  374. const repositoryRef = resolveRepositoryRef(process.env)
  375. rmSync(generatedRoot, { recursive: true, force: true })
  376. /** Reserve one projected path, refusing a second source for it. */
  377. const claim = (target: string, sourceAbs: string): void => {
  378. const holder = claimed.get(target)
  379. if (holder !== undefined && holder !== sourceAbs) {
  380. throw new Error(
  381. `project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}`
  382. + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`,
  383. )
  384. }
  385. claimed.set(target, sourceAbs)
  386. }
  387. for (const page of docsPages) {
  388. if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
  389. routes.add(page.route)
  390. const sourceAbs = resolve(root, page.source)
  391. if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
  392. throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
  393. }
  394. const output = resolve(generatedRoot, page.route)
  395. // Claimed before the images are placed: a page and an image landing on one
  396. // path would otherwise overwrite each other in whichever order they ran.
  397. claim(output, sourceAbs)
  398. mkdirSync(dirname(output), { recursive: true })
  399. const markdown = readFileSync(sourceAbs, 'utf8')
  400. const projected = rewriteMarkdown(markdown, {
  401. sourcePath: page.source,
  402. locale: page.locale,
  403. route: page.route,
  404. pages: docsPages,
  405. repoRoot: root,
  406. repositoryRef,
  407. placeImage: (absPath) => {
  408. const real = publishableImage(absPath, root)
  409. if (real === undefined) {
  410. throw new Error(
  411. `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},`
  412. + ' which is not a regular file inside the repository.',
  413. )
  414. }
  415. // Beside the page that references it, under its own basename: each
  416. // locale's route tree gets its own copy, so one relative URL is correct
  417. // from both.
  418. const name = basename(real)
  419. const target = resolve(dirname(output), name)
  420. claim(target, real)
  421. copyFileSync(real, target)
  422. // Encoded because the destination is a Markdown inline target, where an
  423. // unescaped space would end it early.
  424. return `./${encodeURI(name)}`
  425. },
  426. })
  427. writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))
  428. }
  429. }