project-doc-site.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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. * The same projection also emits a raw-Markdown twin of every route into the
  7. * build output, so a page's URL, minus any trailing slash, plus `.md` serves
  8. * it as plain Markdown.
  9. */
  10. import {
  11. copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
  12. } from 'node:fs'
  13. import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'
  14. import { fromMarkdown } from 'mdast-util-from-markdown'
  15. import { gfmFromMarkdown } from 'mdast-util-gfm'
  16. import { gfm } from 'micromark-extension-gfm'
  17. import type { Nodes } from 'mdast'
  18. import { docsPages, localeCollections, orderedPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
  19. import {
  20. isExternalOrAbsoluteMarkdownUrl,
  21. markdownDestination,
  22. splitMarkdownUrlTarget,
  23. } from './markdown.ts'
  24. const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
  25. const root = resolve(import.meta.dirname, '..')
  26. const generatedRoot = resolve(root, 'website/.generated')
  27. /**
  28. * Resolve the public repository ref used by projected source links.
  29. *
  30. * @param environment Build environment containing an optional explicit public ref.
  31. * @returns The configured public ref, or `master`.
  32. */
  33. export function resolveRepositoryRef(environment: NodeJS.ProcessEnv): string {
  34. return environment.DOCS_REPOSITORY_REF ?? 'master'
  35. }
  36. interface Replacement {
  37. start: number
  38. end: number
  39. value: string
  40. }
  41. type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
  42. /** Inputs for rewriting one canonical Markdown page. */
  43. export interface RewriteMarkdownOptions {
  44. locale: DocsLocale
  45. sourcePath: string
  46. route: string
  47. pages: DocsPage[]
  48. repoRoot: string
  49. repositoryRef: string
  50. /**
  51. * Place one referenced image beside the projected page and return the URL to
  52. * reach it from that page. A GitHub raw URL cannot serve this repository —
  53. * `raw.githubusercontent.com` answers 404 for a private one, and no reader of
  54. * the site is authenticated to it — so an image travels into the generated
  55. * tree and Vite bundles it like any other site asset. Omitted by callers that
  56. * only rewrite text, which then leave images pointing at the repository.
  57. */
  58. placeImage?: (absPath: string) => string
  59. }
  60. function repoPath(absPath: string, repoRoot: string): string {
  61. return relative(repoRoot, absPath).split(sep).join('/')
  62. }
  63. // `#fragment` suffixes pass through verbatim. Generated cordis-surface
  64. // headings carry explicit `<a id>` anchors with the GitHub slug, so those
  65. // fragments resolve on the published site too; hand-written headings rely on
  66. // VitePress's own slugger, which differs from GitHub's for punctuation-heavy
  67. // text — hand-authored cross-page fragments should prefer plain-text headings
  68. // or explicit anchors.
  69. function decodePath(path: string): string {
  70. try {
  71. return decodeURIComponent(path)
  72. } catch {
  73. throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
  74. }
  75. }
  76. function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
  77. const target = posix.relative(posix.dirname(fromRoute), toRoute)
  78. return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
  79. }
  80. function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
  81. const map = new Map<string, Map<DocsLocale, DocsPage>>()
  82. for (const page of pages) {
  83. for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
  84. const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
  85. if (localized.has(page.locale)) {
  86. throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
  87. }
  88. localized.set(page.locale, page)
  89. map.set(source, localized)
  90. }
  91. }
  92. return map
  93. }
  94. function counterpartSource(source: string): string {
  95. return source.endsWith('.zh.md')
  96. ? source.replace(/\.zh\.md$/, '.md')
  97. : source.replace(/\.md$/, '.zh.md')
  98. }
  99. function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
  100. const decoded = decodePath(rawPath)
  101. let absPath = resolve(dirname(sourceAbs), decoded)
  102. if (existsSync(absPath)) return { absPath }
  103. const lineMatch = decoded.match(/:(\d+)$/)
  104. if (lineMatch !== null) {
  105. const lineText = lineMatch[1]
  106. if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
  107. absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
  108. if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
  109. }
  110. if (extname(decoded) === '') {
  111. const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
  112. if (existsSync(markdown)) return { absPath: markdown }
  113. const index = resolve(dirname(sourceAbs), decoded, 'index.md')
  114. if (existsSync(index)) return { absPath: index }
  115. }
  116. throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
  117. }
  118. function githubTarget(
  119. absPath: string,
  120. line: number | undefined,
  121. suffix: string,
  122. repositoryRef: string,
  123. repoRoot: string,
  124. image: boolean,
  125. ): string {
  126. const path = repoPath(absPath, repoRoot)
  127. if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}`
  128. const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
  129. const lineSuffix = line === undefined ? suffix : `#L${line}`
  130. return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
  131. }
  132. /**
  133. * Rewrite repository-relative links without reserializing Markdown.
  134. *
  135. * @param source Markdown text from the canonical file.
  136. * @param options Source, route, manifest, and repository context.
  137. * @returns Markdown whose published links resolve inside the site or to GitHub.
  138. */
  139. export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
  140. const sourceAbs = resolve(options.repoRoot, options.sourcePath)
  141. const published = sourceMap(options.pages)
  142. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  143. const replacements: Replacement[] = []
  144. const rewrite = (node: RewritableNode): void => {
  145. if (isExternalOrAbsoluteMarkdownUrl(node.url)) return
  146. const { path, suffix } = splitMarkdownUrlTarget(node.url)
  147. if (path === '') return
  148. const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
  149. const targetPath = repoPath(absPath, options.repoRoot)
  150. const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
  151. const targetLocale: DocsLocale = isLanguageSwitcher
  152. ? options.locale === 'root' ? 'en' : 'root'
  153. : options.locale
  154. const page = published.get(targetPath)?.get(targetLocale)
  155. const nextUrl = page !== undefined
  156. ? routeTarget(options.route, page.route, suffix)
  157. : node.type === 'image' && options.placeImage !== undefined
  158. // The suffix rides along exactly as the GitHub branch keeps it: an SVG
  159. // view fragment or a Vite query changes what the reference means.
  160. ? `${options.placeImage(absPath)}${suffix}`
  161. : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
  162. const destination = markdownDestination(source, node)
  163. replacements.push({
  164. start: destination.start,
  165. end: destination.end,
  166. value: nextUrl,
  167. })
  168. }
  169. const visit = (node: Nodes): void => {
  170. if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
  171. if ('children' in node) {
  172. for (const child of node.children) visit(child)
  173. }
  174. }
  175. visit(tree)
  176. let projected = source
  177. for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
  178. projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
  179. }
  180. return projected
  181. }
  182. /**
  183. * Record the canonical edit target in VitePress frontmatter.
  184. *
  185. * @param markdown Projected Markdown content.
  186. * @param page Publication manifest entry for the content.
  187. * @returns Markdown with projection-owned frontmatter fields.
  188. */
  189. export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
  190. const fields = [
  191. `editSource: ${JSON.stringify(page.source)}`,
  192. ...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
  193. ].join('\n')
  194. if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
  195. return `---\n${fields}\n---\n\n${markdown}`
  196. }
  197. /** The switcher line a canonical page carries so its GitHub reader can reach the other language. */
  198. const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\([^)]*\)|\[English\]\([^)]*\) \| 中文)$/
  199. /** The repository badge a canonical page carries for its GitHub reader. */
  200. const REPOSITORY_BADGE = /^\[!\[[^\]]*\]\(https:\/\/img\.shields\.io\/[^)]*\)\]\([^)]*\)$/
  201. /**
  202. * Drop the lines that address a canonical page's GitHub reader.
  203. *
  204. * The site carries a locale switcher in its navigation bar and links the
  205. * repository from every page, so projecting these lines would repeat both — the
  206. * switcher as the first element under each heading.
  207. *
  208. * @param markdown Rewritten canonical Markdown content.
  209. * @returns The content without the switcher line or the repository badge.
  210. */
  211. function withoutRepositoryChrome(markdown: string): string {
  212. const lines = markdown.split('\n')
  213. const switcher = lines.findIndex(line => LANGUAGE_SWITCHER.test(line))
  214. // Only the switcher introducing the page qualifies; further down the same
  215. // text is prose or a sample rather than the page's own header.
  216. if (switcher !== -1 && switcher < 8) {
  217. lines.splice(switcher, lines[switcher + 1] === '' ? 2 : 1)
  218. }
  219. const badge = lines.findLastIndex(line => REPOSITORY_BADGE.test(line))
  220. if (badge !== -1) {
  221. lines.splice(lines[badge - 1] === '' ? badge - 1 : badge, lines[badge - 1] === '' ? 2 : 1)
  222. }
  223. return lines.join('\n')
  224. }
  225. /**
  226. * Select the Markdown rendered for one published page.
  227. *
  228. * @param markdown Rewritten canonical Markdown content.
  229. * @param page Publication manifest entry for the content.
  230. * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
  231. */
  232. export function projectedPageContent(markdown: string, page: DocsPage): string {
  233. if (page.sidebar !== null) return withoutRepositoryChrome(markdown)
  234. if (!markdown.startsWith('---\n')) {
  235. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
  236. }
  237. const closingDelimiter = '\n---\n'
  238. const closing = markdown.indexOf(closingDelimiter, 4)
  239. if (closing === -1) {
  240. throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
  241. }
  242. return markdown.slice(0, closing + closingDelimiter.length)
  243. }
  244. /**
  245. * The repository file one image reference resolves to, or `undefined` when the
  246. * target is not a local file this build may publish.
  247. * @param absPath - resolved image target.
  248. * @param repoRoot - repository root every published image must stay inside.
  249. * @returns the file's real path, or `undefined` when it must not be copied.
  250. *
  251. * Only a regular file whose real path stays inside the repository qualifies.
  252. * Publication copies the bytes into the site, so a reference escaping the
  253. * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree —
  254. * would put a build-machine file on the site; `existsSync` alone, which is all
  255. * link resolution needs, does not answer that.
  256. */
  257. export function publishableImage(absPath: string, repoRoot: string): string | undefined {
  258. const real = realpathSync(absPath)
  259. const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`)
  260. return inside && statSync(real).isFile() ? real : undefined
  261. }
  262. /** Every local image a published page references, resolved to its repository file. */
  263. function referencedImages(): string[] {
  264. const found = new Set<string>()
  265. for (const page of docsPages) {
  266. const sourceAbs = resolve(root, page.source)
  267. if (!existsSync(sourceAbs)) continue
  268. rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), {
  269. sourcePath: page.source,
  270. locale: page.locale,
  271. route: page.route,
  272. pages: docsPages,
  273. repoRoot: root,
  274. repositoryRef: 'master',
  275. placeImage: (absPath) => {
  276. const real = publishableImage(absPath, root)
  277. if (real !== undefined) found.add(real)
  278. return ''
  279. },
  280. })
  281. }
  282. return [...found]
  283. }
  284. /**
  285. * Files watched by the local VitePress dev server: every canonical Markdown
  286. * source, plus the images they publish. Without the images, replacing a
  287. * screenshot leaves the previous copy in the generated tree until something
  288. * touches the Markdown beside it.
  289. */
  290. export function docsSourceFiles(): string[] {
  291. return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])]
  292. }
  293. /** Manifest and repository inputs for one projection pass. */
  294. export interface ProjectionContext {
  295. /** Pages to project. */
  296. pages: DocsPage[]
  297. /** Repository root every source and placed image must live under. */
  298. repoRoot: string
  299. /** Public ref used by projected GitHub links. */
  300. repositoryRef: string
  301. }
  302. function defaultProjectionContext(): ProjectionContext {
  303. return { pages: docsPages, repoRoot: root, repositoryRef: resolveRepositoryRef(process.env) }
  304. }
  305. /**
  306. * Project every page and its images into one target tree.
  307. *
  308. * `entries` are what gets emitted; link resolution always reads the canonical
  309. * `context.pages`, so an alias entry sharing a source with its index route
  310. * emits at its own path while links keep targeting canonical routes.
  311. */
  312. function projectPagesInto(
  313. targetRoot: string,
  314. context: ProjectionContext,
  315. pageContent: (markdown: string, page: DocsPage) => string,
  316. entries: DocsPage[] = context.pages,
  317. ): void {
  318. const routes = new Set<string>()
  319. /** Projected path to the repository file that claimed it, pages and images alike. */
  320. const claimed = new Map<string, string>()
  321. /** Reserve one projected path, refusing a second source for it. */
  322. const claim = (target: string, sourceAbs: string): void => {
  323. const holder = claimed.get(target)
  324. if (holder !== undefined && holder !== sourceAbs) {
  325. throw new Error(
  326. `project-doc-site: ${repoPath(sourceAbs, context.repoRoot)} and ${repoPath(holder, context.repoRoot)}`
  327. + ` both project to ${relative(targetRoot, target).split(sep).join('/')}.`,
  328. )
  329. }
  330. // A file the projection did not claim is another producer's output — in
  331. // the twin pass, the build VitePress just wrote, including `public/`
  332. // copies. Overwriting one would silently corrupt the site.
  333. if (holder === undefined && existsSync(target)) {
  334. throw new Error(
  335. `project-doc-site: ${repoPath(sourceAbs, context.repoRoot)} would overwrite existing build file`
  336. + ` ${relative(targetRoot, target).split(sep).join('/')}.`,
  337. )
  338. }
  339. claimed.set(target, sourceAbs)
  340. }
  341. for (const page of entries) {
  342. if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
  343. routes.add(page.route)
  344. const sourceAbs = resolve(context.repoRoot, page.source)
  345. if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
  346. throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
  347. }
  348. const output = resolve(targetRoot, page.route)
  349. // Claimed before the images are placed: a page and an image landing on one
  350. // path would otherwise overwrite each other in whichever order they ran.
  351. claim(output, sourceAbs)
  352. mkdirSync(dirname(output), { recursive: true })
  353. const markdown = readFileSync(sourceAbs, 'utf8')
  354. const projected = rewriteMarkdown(markdown, {
  355. sourcePath: page.source,
  356. locale: page.locale,
  357. route: page.route,
  358. pages: context.pages,
  359. repoRoot: context.repoRoot,
  360. repositoryRef: context.repositoryRef,
  361. placeImage: (absPath) => {
  362. const real = publishableImage(absPath, context.repoRoot)
  363. if (real === undefined) {
  364. throw new Error(
  365. `project-doc-site: ${page.source} references image ${repoPath(absPath, context.repoRoot)},`
  366. + ' which is not a regular file inside the repository.',
  367. )
  368. }
  369. // Beside the page that references it, under its own basename: each
  370. // locale's route tree gets its own copy, so one relative URL is correct
  371. // from both.
  372. const name = basename(real)
  373. const target = resolve(dirname(output), name)
  374. claim(target, real)
  375. copyFileSync(real, target)
  376. // Encoded because the destination is a Markdown inline target, where an
  377. // unescaped space would end it early.
  378. return `./${encodeURI(name)}`
  379. },
  380. })
  381. writeFileSync(output, pageContent(projected, page))
  382. }
  383. }
  384. /** Rebuild the disposable VitePress source tree from the publication manifest. */
  385. export function projectDocs(): void {
  386. rmSync(generatedRoot, { recursive: true, force: true })
  387. projectPagesInto(generatedRoot, defaultProjectionContext(), (markdown, page) =>
  388. addProjectionFrontmatter(projectedPageContent(markdown, page), page))
  389. }
  390. /**
  391. * Strip the leading YAML frontmatter of a projected page.
  392. *
  393. * @param markdown Rewritten canonical Markdown content.
  394. * @param source Repository-relative page source, named by the failure.
  395. * @returns The content after the frontmatter block, or the input when none opens it.
  396. */
  397. function withoutFrontmatter(markdown: string, source: string): string {
  398. if (!markdown.startsWith('---\n')) return markdown
  399. const closingDelimiter = '\n---\n'
  400. const closing = markdown.indexOf(closingDelimiter, 4)
  401. if (closing === -1) {
  402. throw new Error(`project-doc-site: ${JSON.stringify(source)} has unclosed YAML frontmatter.`)
  403. }
  404. return markdown.slice(closing + closingDelimiter.length).replace(/^\n+/, '')
  405. }
  406. /**
  407. * The raw-Markdown twin of one published page.
  408. *
  409. * Frontmatter is VitePress rendering configuration and is dropped. A locale
  410. * home page therefore keeps its body here, while the rendered site truncates
  411. * it to the frontmatter redirect.
  412. *
  413. * @param markdown Rewritten canonical Markdown content.
  414. * @param source Repository-relative page source, named by frontmatter failures.
  415. * @returns Plain Markdown without frontmatter or repository chrome.
  416. */
  417. export function rawMarkdownPageContent(markdown: string, source: string): string {
  418. return withoutRepositoryChrome(withoutFrontmatter(markdown, source))
  419. }
  420. /**
  421. * Parent-level alias route of an index route, or `undefined` for other routes.
  422. *
  423. * The rendered site shows an index route as a directory URL, so "append
  424. * `.md`" naturally lands on `<dir>.md` once the trailing slash is dropped.
  425. * The root `index.md` has no parent to alias into.
  426. */
  427. function indexAliasRoute(route: string): string | undefined {
  428. const match = /^(.+)\/index\.md$/.exec(route)
  429. return match?.[1] === undefined ? undefined : `${match[1]}.md`
  430. }
  431. /**
  432. * Site-relative Markdown files the raw-Markdown projection emits: every
  433. * route, plus one parent-level alias per index route.
  434. *
  435. * @param pages Pages to project, defaulting to the publication manifest.
  436. * @returns The emitted paths, routes first.
  437. */
  438. export function rawMarkdownFiles(pages: DocsPage[] = docsPages): string[] {
  439. const aliases = pages.map(page => indexAliasRoute(page.route)).filter(alias => alias !== undefined)
  440. return [...pages.map(page => page.route), ...aliases]
  441. }
  442. /**
  443. * Emit the raw-Markdown twin of every published route into a built site, so
  444. * static hosting serves the page's URL, minus any trailing slash, plus `.md`
  445. * as plain Markdown. Each index route also emits a parent-level alias twin,
  446. * projected over the alias route so its relative links stay correct.
  447. * Referenced images are copied beside the pages, keeping the same relative
  448. * URLs valid in both trees. Existing build files stay in place, and a name
  449. * collision with one fails the emission.
  450. *
  451. * @param outDir Build output directory to emit into.
  452. * @param context Manifest and repository inputs, defaulting to this repository.
  453. */
  454. export function emitRawMarkdownPages(outDir: string, context: ProjectionContext = defaultProjectionContext()): void {
  455. const aliases = context.pages.flatMap((page) => {
  456. const alias = indexAliasRoute(page.route)
  457. return alias === undefined ? [] : [{ ...page, route: alias }]
  458. })
  459. projectPagesInto(
  460. outDir,
  461. context,
  462. (markdown, page) => rawMarkdownPageContent(markdown, page.source),
  463. [...context.pages, ...aliases],
  464. )
  465. }
  466. /**
  467. * Raw Markdown served for one site route.
  468. *
  469. * Dev-server counterpart of {@link emitRawMarkdownPages}: images are not
  470. * copied because the generated tree already serves them beside the page.
  471. *
  472. * @param route Manifest route, including its `.md` suffix.
  473. * @param context Manifest and repository inputs, defaulting to this repository.
  474. * @returns The projected page, or `undefined` when the manifest does not publish the route.
  475. */
  476. export function rawMarkdownRoute(route: string, context: ProjectionContext = defaultProjectionContext()): string | undefined {
  477. const page = context.pages.find(candidate => candidate.route === route)
  478. if (page === undefined) return undefined
  479. const markdown = readFileSync(resolve(context.repoRoot, page.source), 'utf8')
  480. return rawMarkdownPageContent(rewriteMarkdown(markdown, {
  481. sourcePath: page.source,
  482. locale: page.locale,
  483. route: page.route,
  484. pages: context.pages,
  485. repoRoot: context.repoRoot,
  486. repositoryRef: context.repositoryRef,
  487. placeImage: absPath => `./${encodeURI(basename(absPath))}`,
  488. }), page.source)
  489. }
  490. /** Site identity written into llms.txt. */
  491. export interface LlmsTxtSite {
  492. /** Site base path, carrying the leading and trailing slashes VitePress requires. */
  493. base: string
  494. /** Site title. */
  495. title: string
  496. /** Site description. */
  497. description: string
  498. }
  499. /** Locale groups llms.txt lists, in the order the site's navigation presents them. */
  500. const llmsTxtLocales: readonly { heading: string; locale: DocsLocale }[] = [
  501. { heading: '简体中文', locale: 'root' },
  502. { heading: 'English', locale: 'en' },
  503. ]
  504. /**
  505. * The llms.txt index of every published page's raw-Markdown twin.
  506. *
  507. * Links are site-absolute so an agent resolves them against the host it
  508. * fetched llms.txt from; locale home pages stay out because this file is the
  509. * agent-facing entry point itself.
  510. *
  511. * @param site Site identity and base path.
  512. * @returns llms.txt content listing both locale trees.
  513. */
  514. export function llmsTxt(site: LlmsTxtSite): string {
  515. const lines = [
  516. `# ${site.title}`,
  517. '',
  518. `> ${site.description}`,
  519. '',
  520. '页面 URL 去掉末尾斜杠再加 `.md` 即为该页原始 Markdown(根路径用 `/index.md`);下方列表是各页精确地址。Drop any trailing slash and append `.md` to a page URL for its raw Markdown (the site root is `/index.md`); the list below carries the exact addresses.',
  521. ]
  522. for (const { heading, locale } of llmsTxtLocales) {
  523. lines.push('', `## ${heading}`, '')
  524. for (const collection of localeCollections[locale]) {
  525. for (const page of orderedPages(locale, collection)) {
  526. lines.push(`- [${page.label}](${site.base}${page.route}): ${page.section}`)
  527. }
  528. }
  529. }
  530. return `${lines.join('\n')}\n`
  531. }