project-doc-site.ts 16 KB

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