project-doc-site.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. interface DestinationRange {
  23. start: number
  24. end: number
  25. }
  26. type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
  27. /** Inputs for rewriting one canonical Markdown page. */
  28. export interface RewriteMarkdownOptions {
  29. locale: DocsLocale
  30. sourcePath: string
  31. route: string
  32. pages: DocsPage[]
  33. repoRoot: string
  34. repositoryRef: string
  35. }
  36. function repoPath(absPath: string, repoRoot: string): string {
  37. return relative(repoRoot, absPath).split(sep).join('/')
  38. }
  39. function isExternalOrSiteAbsolute(url: string): boolean {
  40. return url.startsWith('#')
  41. || url.startsWith('//')
  42. || url.startsWith('/')
  43. || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
  44. }
  45. function skipWhitespace(source: string, start: number): number {
  46. let index = start
  47. while (/\s/.test(source[index] ?? '')) index += 1
  48. return index
  49. }
  50. function labelEnd(source: string): number {
  51. const first = source.indexOf('[')
  52. if (first === -1) return -1
  53. let depth = 0
  54. for (let index = first; index < source.length; index += 1) {
  55. const char = source[index]
  56. if (char === '\\') {
  57. index += 1
  58. } else if (char === '[') {
  59. depth += 1
  60. } else if (char === ']') {
  61. depth -= 1
  62. if (depth === 0) return index
  63. }
  64. }
  65. return -1
  66. }
  67. function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
  68. const endOfLabel = labelEnd(rawNode)
  69. if (endOfLabel === -1) {
  70. throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
  71. }
  72. let start: number
  73. if (type === 'definition') {
  74. const colon = rawNode.indexOf(':', endOfLabel + 1)
  75. if (colon === -1) {
  76. throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
  77. }
  78. start = skipWhitespace(rawNode, colon + 1)
  79. } else {
  80. if (rawNode[endOfLabel + 1] !== '(') {
  81. throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
  82. }
  83. start = skipWhitespace(rawNode, endOfLabel + 2)
  84. }
  85. if (rawNode[start] === '<') {
  86. for (let index = start + 1; index < rawNode.length; index += 1) {
  87. if (rawNode[index] === '\\') index += 1
  88. else if (rawNode[index] === '>') return { start: start + 1, end: index }
  89. }
  90. throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
  91. }
  92. let depth = 0
  93. for (let index = start; index < rawNode.length; index += 1) {
  94. const char = rawNode[index]
  95. if (char === '\\') {
  96. index += 1
  97. } else if (char === '(') {
  98. depth += 1
  99. } else if (char === ')') {
  100. if (depth === 0) return { start, end: index }
  101. depth -= 1
  102. } else if (/\s/.test(char ?? '') && depth === 0) {
  103. return { start, end: index }
  104. }
  105. }
  106. return { start, end: rawNode.length }
  107. }
  108. function splitTarget(url: string): { path: string; suffix: string } {
  109. const boundary = url.search(/[?#]/)
  110. if (boundary === -1) return { path: url, suffix: '' }
  111. return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
  112. }
  113. function decodePath(path: string): string {
  114. try {
  115. return decodeURIComponent(path)
  116. } catch {
  117. throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
  118. }
  119. }
  120. function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
  121. const target = posix.relative(posix.dirname(fromRoute), toRoute)
  122. return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
  123. }
  124. function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
  125. const map = new Map<string, Map<DocsLocale, DocsPage>>()
  126. for (const page of pages) {
  127. for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
  128. const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
  129. if (localized.has(page.locale)) {
  130. throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
  131. }
  132. localized.set(page.locale, page)
  133. map.set(source, localized)
  134. }
  135. }
  136. return map
  137. }
  138. function counterpartSource(source: string): string {
  139. return source.endsWith('.zh.md')
  140. ? source.replace(/\.zh\.md$/, '.md')
  141. : source.replace(/\.md$/, '.zh.md')
  142. }
  143. function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
  144. const decoded = decodePath(rawPath)
  145. let absPath = resolve(dirname(sourceAbs), decoded)
  146. if (existsSync(absPath)) return { absPath }
  147. const lineMatch = decoded.match(/:(\d+)$/)
  148. if (lineMatch !== null) {
  149. const lineText = lineMatch[1]
  150. if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
  151. absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
  152. if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
  153. }
  154. if (extname(decoded) === '') {
  155. const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
  156. if (existsSync(markdown)) return { absPath: markdown }
  157. const index = resolve(dirname(sourceAbs), decoded, 'index.md')
  158. if (existsSync(index)) return { absPath: index }
  159. }
  160. throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
  161. }
  162. function githubTarget(
  163. absPath: string,
  164. line: number | undefined,
  165. suffix: string,
  166. repositoryRef: string,
  167. repoRoot: string,
  168. image: boolean,
  169. ): string {
  170. const path = repoPath(absPath, repoRoot)
  171. if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
  172. const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
  173. const lineSuffix = line === undefined ? suffix : `#L${line}`
  174. return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
  175. }
  176. /**
  177. * Rewrite repository-relative links without reserializing Markdown.
  178. *
  179. * @param source Markdown text from the canonical file.
  180. * @param options Source, route, manifest, and repository context.
  181. * @returns Markdown whose published links resolve inside the site or to GitHub.
  182. */
  183. export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
  184. const sourceAbs = resolve(options.repoRoot, options.sourcePath)
  185. const published = sourceMap(options.pages)
  186. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  187. const replacements: Replacement[] = []
  188. const rewrite = (node: RewritableNode): void => {
  189. if (isExternalOrSiteAbsolute(node.url)) return
  190. const { path, suffix } = splitTarget(node.url)
  191. if (path === '') return
  192. const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
  193. const targetPath = repoPath(absPath, options.repoRoot)
  194. const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
  195. const targetLocale: DocsLocale = isLanguageSwitcher
  196. ? options.locale === 'root' ? 'en' : 'root'
  197. : options.locale
  198. const page = published.get(targetPath)?.get(targetLocale)
  199. const nextUrl = page === undefined
  200. ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
  201. : routeTarget(options.route, page.route, suffix)
  202. const start = node.position?.start.offset
  203. const end = node.position?.end.offset
  204. if (start === undefined || end === undefined) {
  205. throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
  206. }
  207. const rawNode = source.slice(start, end)
  208. const rawDestination = destinationRange(rawNode, node.type)
  209. replacements.push({
  210. start: start + rawDestination.start,
  211. end: start + rawDestination.end,
  212. value: nextUrl,
  213. })
  214. }
  215. const visit = (node: Nodes): void => {
  216. if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
  217. if ('children' in node) {
  218. for (const child of node.children) visit(child)
  219. }
  220. }
  221. visit(tree)
  222. let projected = source
  223. for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
  224. projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
  225. }
  226. return projected
  227. }
  228. /**
  229. * Record the canonical edit target in VitePress frontmatter.
  230. *
  231. * @param markdown Projected Markdown content.
  232. * @param sourcePath Repository-relative canonical source path.
  233. * @returns Markdown with an `editSource` frontmatter field.
  234. */
  235. export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
  236. const field = `editSource: ${JSON.stringify(sourcePath)}`
  237. if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
  238. return `---\n${field}\n---\n\n${markdown}`
  239. }
  240. /**
  241. * Select the Markdown rendered for one published page.
  242. *
  243. * @param markdown Rewritten canonical Markdown content.
  244. * @param page Publication manifest entry for the content.
  245. * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
  246. */
  247. export function projectedPageContent(markdown: string, page: DocsPage): string {
  248. if (page.sidebar !== null) return markdown
  249. if (!markdown.startsWith('---\n')) {
  250. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
  251. }
  252. const closingDelimiter = '\n---\n'
  253. const closing = markdown.indexOf(closingDelimiter, 4)
  254. if (closing === -1) {
  255. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
  256. }
  257. return markdown.slice(0, closing + closingDelimiter.length)
  258. }
  259. /** Canonical Markdown files watched by the local VitePress dev server. */
  260. export function docsSourceFiles(): string[] {
  261. return [...new Set(docsPages.map(page => resolve(root, page.source)))]
  262. }
  263. /** Rebuild the disposable VitePress source tree from the publication manifest. */
  264. export function projectDocs(): void {
  265. const routes = new Set<string>()
  266. const repositoryRef = process.env.GITHUB_SHA ?? 'master'
  267. rmSync(generatedRoot, { recursive: true, force: true })
  268. for (const page of docsPages) {
  269. if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
  270. routes.add(page.route)
  271. const sourceAbs = resolve(root, page.source)
  272. if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
  273. throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
  274. }
  275. const output = resolve(generatedRoot, page.route)
  276. mkdirSync(dirname(output), { recursive: true })
  277. const markdown = readFileSync(sourceAbs, 'utf8')
  278. const projected = rewriteMarkdown(markdown, {
  279. sourcePath: page.source,
  280. locale: page.locale,
  281. route: page.route,
  282. pages: docsPages,
  283. repoRoot: root,
  284. repositoryRef,
  285. })
  286. writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
  287. }
  288. }