project-doc-site.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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 { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  8. import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
  9. import { fromMarkdown } from 'mdast-util-from-markdown'
  10. import { gfmFromMarkdown } from 'mdast-util-gfm'
  11. import { gfm } from 'micromark-extension-gfm'
  12. import type { Nodes } from 'mdast'
  13. import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
  14. const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
  15. const root = resolve(import.meta.dirname, '..')
  16. const generatedRoot = resolve(root, 'website/.generated')
  17. interface Replacement {
  18. start: number
  19. end: number
  20. value: string
  21. }
  22. /** Inputs for rewriting one canonical Markdown page. */
  23. export interface RewriteMarkdownOptions {
  24. locale: DocsLocale
  25. sourcePath: string
  26. route: string
  27. pages: DocsPage[]
  28. repoRoot: string
  29. repositoryRef: string
  30. }
  31. function repoPath(absPath: string, repoRoot: string): string {
  32. return relative(repoRoot, absPath).split(sep).join('/')
  33. }
  34. function isExternalOrSiteAbsolute(url: string): boolean {
  35. return url.startsWith('#')
  36. || url.startsWith('//')
  37. || url.startsWith('/')
  38. || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
  39. }
  40. function splitTarget(url: string): { path: string; suffix: string } {
  41. const boundary = url.search(/[?#]/)
  42. if (boundary === -1) return { path: url, suffix: '' }
  43. return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
  44. }
  45. function decodePath(path: string): string {
  46. try {
  47. return decodeURIComponent(path)
  48. } catch {
  49. throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
  50. }
  51. }
  52. function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
  53. const target = posix.relative(posix.dirname(fromRoute), toRoute)
  54. return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
  55. }
  56. function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
  57. const map = new Map<string, Map<DocsLocale, DocsPage>>()
  58. for (const page of pages) {
  59. for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
  60. const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
  61. if (localized.has(page.locale)) {
  62. throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
  63. }
  64. localized.set(page.locale, page)
  65. map.set(source, localized)
  66. }
  67. }
  68. return map
  69. }
  70. function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
  71. const decoded = decodePath(rawPath)
  72. let absPath = resolve(dirname(sourceAbs), decoded)
  73. if (existsSync(absPath)) return { absPath }
  74. const lineMatch = decoded.match(/:(\d+)$/)
  75. if (lineMatch !== null) {
  76. const lineText = lineMatch[1]
  77. if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
  78. absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
  79. if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
  80. }
  81. if (extname(decoded) === '') {
  82. const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
  83. if (existsSync(markdown)) return { absPath: markdown }
  84. const index = resolve(dirname(sourceAbs), decoded, 'index.md')
  85. if (existsSync(index)) return { absPath: index }
  86. }
  87. throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
  88. }
  89. function githubTarget(
  90. absPath: string,
  91. line: number | undefined,
  92. suffix: string,
  93. repositoryRef: string,
  94. repoRoot: string,
  95. image: boolean,
  96. ): string {
  97. const path = repoPath(absPath, repoRoot)
  98. if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
  99. const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
  100. const lineSuffix = line === undefined ? suffix : `#L${line}`
  101. return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
  102. }
  103. /**
  104. * Rewrite repository-relative links without reserializing Markdown.
  105. *
  106. * @param source Markdown text from the canonical file.
  107. * @param options Source, route, manifest, and repository context.
  108. * @returns Markdown whose published links resolve inside the site or to GitHub.
  109. */
  110. export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
  111. const sourceAbs = resolve(options.repoRoot, options.sourcePath)
  112. const published = sourceMap(options.pages)
  113. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  114. const replacements: Replacement[] = []
  115. const rewrite = (node: Nodes & { url: string }): void => {
  116. if (isExternalOrSiteAbsolute(node.url)) return
  117. const { path, suffix } = splitTarget(node.url)
  118. if (path === '') return
  119. const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
  120. const targetPath = repoPath(absPath, options.repoRoot)
  121. const page = published.get(targetPath)?.get(options.locale)
  122. const nextUrl = page === undefined
  123. ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
  124. : routeTarget(options.route, page.route, suffix)
  125. const start = node.position?.start.offset
  126. const end = node.position?.end.offset
  127. if (start === undefined || end === undefined) {
  128. throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
  129. }
  130. const rawNode = source.slice(start, end)
  131. const urlOffset = rawNode.lastIndexOf(node.url)
  132. if (urlOffset === -1) {
  133. throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`)
  134. }
  135. replacements.push({
  136. start: start + urlOffset,
  137. end: start + urlOffset + node.url.length,
  138. value: nextUrl,
  139. })
  140. }
  141. const visit = (node: Nodes): void => {
  142. if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
  143. if ('children' in node) {
  144. for (const child of node.children) visit(child)
  145. }
  146. }
  147. visit(tree)
  148. let projected = source
  149. for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
  150. projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
  151. }
  152. return projected
  153. }
  154. /**
  155. * Record the canonical edit target in VitePress frontmatter.
  156. *
  157. * @param markdown Projected Markdown content.
  158. * @param sourcePath Repository-relative canonical source path.
  159. * @returns Markdown with an `editSource` frontmatter field.
  160. */
  161. export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
  162. const field = `editSource: ${JSON.stringify(sourcePath)}`
  163. if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
  164. return `---\n${field}\n---\n\n${markdown}`
  165. }
  166. /** Canonical Markdown files watched by the local VitePress dev server. */
  167. export function docsSourceFiles(): string[] {
  168. return [...new Set(docsPages.map(page => resolve(root, page.source)))]
  169. }
  170. /** Rebuild the disposable VitePress source tree from the publication manifest. */
  171. export function projectDocs(): void {
  172. const routes = new Set<string>()
  173. const repositoryRef = process.env.GITHUB_SHA ?? 'master'
  174. rmSync(generatedRoot, { recursive: true, force: true })
  175. for (const page of docsPages) {
  176. if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
  177. routes.add(page.route)
  178. const sourceAbs = resolve(root, page.source)
  179. if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
  180. throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
  181. }
  182. const output = resolve(generatedRoot, page.route)
  183. mkdirSync(dirname(output), { recursive: true })
  184. const markdown = readFileSync(sourceAbs, 'utf8')
  185. const projected = rewriteMarkdown(markdown, {
  186. sourcePath: page.source,
  187. locale: page.locale,
  188. route: page.route,
  189. pages: docsPages,
  190. repoRoot: root,
  191. repositoryRef,
  192. })
  193. writeFileSync(output, addProjectionFrontmatter(projected, page.source))
  194. }
  195. }