project-doc-site.spec.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  1. /** Tests for the documentation website projection adapter. */
  2. import { execFileSync } from 'node:child_process'
  3. import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { basename, dirname, join, resolve } from 'node:path'
  6. import { fromMarkdown } from 'mdast-util-from-markdown'
  7. import { gfmFromMarkdown } from 'mdast-util-gfm'
  8. import { gfm } from 'micromark-extension-gfm'
  9. import type { Nodes } from 'mdast'
  10. import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
  11. import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts'
  12. import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
  13. import {
  14. addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage,
  15. rawMarkdownFiles, rawMarkdownPageContent, rawMarkdownRoute, resolveRepositoryRef, rewriteMarkdown,
  16. } from './project-doc-site.ts'
  17. const roots: string[] = []
  18. const repositoryRoot = resolve(import.meta.dirname, '..')
  19. function unexpectedWebsiteMarkdown(files: readonly string[]): string[] {
  20. return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort()
  21. }
  22. afterEach(() => {
  23. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  24. })
  25. function fixture(): { root: string; pages: DocsPage[] } {
  26. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
  27. roots.push(root)
  28. mkdirSync(join(root, 'docs'), { recursive: true })
  29. mkdirSync(join(root, 'packages'), { recursive: true })
  30. writeFileSync(join(root, 'docs/a.md'), '# A\n')
  31. writeFileSync(join(root, 'docs/b.md'), '# B\n')
  32. writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
  33. writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
  34. writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
  35. return {
  36. root,
  37. pages: [
  38. { locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
  39. { locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
  40. { locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
  41. { locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
  42. ],
  43. }
  44. }
  45. describe('website source layout', () => {
  46. it('rejects Markdown outside the subtree instructions', () => {
  47. expect(unexpectedWebsiteMarkdown([
  48. 'website/AGENTS.md',
  49. 'website/docs.ts',
  50. 'website/zh-CN/api/harness/service.md',
  51. ])).toEqual(['website/zh-CN/api/harness/service.md'])
  52. })
  53. it('contains no tracked or unignored documentation copies', () => {
  54. const files = execFileSync(
  55. 'git',
  56. ['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'],
  57. { cwd: repositoryRoot, encoding: 'utf8' },
  58. ).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file)))
  59. expect(
  60. unexpectedWebsiteMarkdown(files),
  61. 'Keep canonical Markdown under docs/ and publish it through website/docs.ts.',
  62. ).toEqual([])
  63. })
  64. })
  65. describe('documentation site build', () => {
  66. it.each([
  67. { mode: 'SPA', mpa: false, expectedMpa: undefined },
  68. { mode: 'MPA', mpa: true, expectedMpa: 'true' },
  69. ])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => {
  70. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-'))
  71. roots.push(root)
  72. const outDir = join(root, '.dist')
  73. const stale = join(outDir, 'stale.md')
  74. mkdirSync(outDir)
  75. writeFileSync(stale, 'stale\n')
  76. const options = docSiteBuildOptions(root, mpa)
  77. expect(options.mpa).toBe(expectedMpa)
  78. expect(existsSync(stale)).toBe(true)
  79. await options.onAfterConfigResolve?.({ outDir } as never)
  80. expect(existsSync(outDir)).toBe(false)
  81. })
  82. it('refuses to remove the site root or an outside directory', () => {
  83. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-'))
  84. const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-'))
  85. roots.push(root, outside)
  86. writeFileSync(join(root, 'keep'), 'root\n')
  87. writeFileSync(join(outside, 'keep'), 'outside\n')
  88. expect(() => {
  89. cleanDocSiteOutput(root, root)
  90. }).toThrow('must be a child of site root')
  91. expect(() => {
  92. cleanDocSiteOutput(root, outside)
  93. }).toThrow('must be a child of site root')
  94. expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n')
  95. expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n')
  96. })
  97. it('unlinks a link-shaped output without removing its target', () => {
  98. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-root-'))
  99. const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-target-'))
  100. roots.push(root, outside)
  101. const outDir = join(root, '.dist')
  102. const keep = join(outside, 'keep')
  103. writeFileSync(keep, 'outside\n')
  104. symlinkSync(outside, outDir, 'junction')
  105. cleanDocSiteOutput(root, outDir)
  106. expect(existsSync(outDir)).toBe(false)
  107. expect(readFileSync(keep, 'utf8')).toBe('outside\n')
  108. })
  109. it('refuses output whose nearest existing parent resolves outside the site root', () => {
  110. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-root-'))
  111. const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-target-'))
  112. roots.push(root, outside)
  113. const linkedParent = join(root, 'linked')
  114. const outDir = join(linkedParent, 'missing', '.dist')
  115. const keep = join(outside, 'keep')
  116. writeFileSync(keep, 'outside\n')
  117. symlinkSync(outside, linkedParent, 'junction')
  118. try {
  119. expect(() => {
  120. cleanDocSiteOutput(root, outDir)
  121. }).toThrow('must resolve inside site root')
  122. expect(readFileSync(keep, 'utf8')).toBe('outside\n')
  123. } finally {
  124. unlinkSync(linkedParent)
  125. }
  126. })
  127. })
  128. describe('publishableImage', () => {
  129. it('accepts a regular file inside the repository', () => {
  130. const { root } = fixture()
  131. const real = realpathSync(join(root, 'packages/logo.svg'))
  132. expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real)
  133. })
  134. it('refuses a target whose real path escapes the repository', () => {
  135. // Publication copies the bytes onto the site, so a reference reaching a
  136. // build-machine file must not be treated as an image the repository owns.
  137. const { root } = fixture()
  138. const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-'))
  139. roots.push(outside)
  140. writeFileSync(join(outside, 'secret.png'), 'not really a png\n')
  141. symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png'))
  142. expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined()
  143. expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined()
  144. })
  145. it('refuses a directory', () => {
  146. const { root } = fixture()
  147. expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined()
  148. })
  149. })
  150. describe('resolveRepositoryRef', () => {
  151. it('defaults to public master instead of a private workflow SHA', () => {
  152. expect(resolveRepositoryRef({ GITHUB_SHA: 'private-sha' })).toBe('master')
  153. })
  154. it('accepts an explicit public repository ref', () => {
  155. expect(resolveRepositoryRef({ DOCS_REPOSITORY_REF: 'public-sha' })).toBe('public-sha')
  156. })
  157. })
  158. describe('rewriteMarkdown', () => {
  159. it('maps published pages and pins unpublished source links', () => {
  160. const { root, pages } = fixture()
  161. const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
  162. expect(rewriteMarkdown(source, {
  163. locale: 'en',
  164. sourcePath: 'docs/a.md',
  165. route: 'en/a.md',
  166. pages,
  167. repoRoot: root,
  168. repositoryRef: 'abc123',
  169. })).toBe(
  170. '[B](./reference/b.md#part) '
  171. + '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
  172. + '[web](https://example.com)\n',
  173. )
  174. })
  175. it('selects the published target in the current site locale', () => {
  176. const { root, pages } = fixture()
  177. expect(rewriteMarkdown('[B](b.md)\n', {
  178. locale: 'root',
  179. sourcePath: 'docs/a.md',
  180. route: 'a.md',
  181. pages,
  182. repoRoot: root,
  183. repositoryRef: 'abc123',
  184. })).toBe('[B](./reference-root/b.md)\n')
  185. })
  186. it('uses raw GitHub content for unpublished images when nothing places them', () => {
  187. const { root, pages } = fixture()
  188. expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
  189. locale: 'en',
  190. sourcePath: 'docs/a.md',
  191. route: 'en/a.md',
  192. pages,
  193. repoRoot: root,
  194. repositoryRef: 'abc123',
  195. })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/abc123/packages/logo.svg)\n')
  196. })
  197. it('hands an image to the placer and uses the URL it returns', () => {
  198. // A raw GitHub URL cannot serve a private repository, so the site build
  199. // carries images itself; the placer is what puts them there. The stand-in
  200. // derives its URL the way the real one does, so a placer that stopped
  201. // returning the basename would fail here rather than pass on a constant.
  202. const { root, pages } = fixture()
  203. const placed: string[] = []
  204. expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
  205. locale: 'en',
  206. sourcePath: 'docs/a.md',
  207. route: 'en/a.md',
  208. pages,
  209. repoRoot: root,
  210. repositoryRef: 'abc123',
  211. placeImage: (absPath) => {
  212. const name = basename(absPath)
  213. placed.push(name)
  214. return `./${name}`
  215. },
  216. })).toBe('![logo](./logo.svg)\n')
  217. expect(placed).toEqual(['logo.svg'])
  218. })
  219. it('keeps a placed image\u2019s query or fragment', () => {
  220. // An SVG view fragment and a Vite query both change what the reference
  221. // means, and the GitHub branch has always carried them.
  222. const { root, pages } = fixture()
  223. expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', {
  224. locale: 'en',
  225. sourcePath: 'docs/a.md',
  226. route: 'en/a.md',
  227. pages,
  228. repoRoot: root,
  229. repositoryRef: 'abc123',
  230. placeImage: absPath => `./${basename(absPath)}`,
  231. })).toBe('![logo](./logo.svg#view)\n')
  232. })
  233. it('leaves a published page link to the route even when a placer exists', () => {
  234. const { root, pages } = fixture()
  235. expect(rewriteMarkdown('[B](b.md)\n', {
  236. locale: 'en',
  237. sourcePath: 'docs/a.md',
  238. route: 'en/a.md',
  239. pages,
  240. repoRoot: root,
  241. repositoryRef: 'abc123',
  242. placeImage: () => { throw new Error('a page link must not be placed as an asset') },
  243. })).toBe('[B](./reference/b.md)\n')
  244. })
  245. it('does not rewrite Markdown-looking text inside code fences', () => {
  246. const { root, pages } = fixture()
  247. const source = '```md\n[B](b.md)\n```\n'
  248. expect(rewriteMarkdown(source, {
  249. locale: 'en',
  250. sourcePath: 'docs/a.md',
  251. route: 'en/a.md',
  252. pages,
  253. repoRoot: root,
  254. repositoryRef: 'abc123',
  255. })).toBe(source)
  256. })
  257. it('replaces the destination token without changing repeated titles or escapes', () => {
  258. const { root, pages } = fixture()
  259. const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
  260. expect(rewriteMarkdown(source, {
  261. locale: 'en',
  262. sourcePath: 'docs/a.md',
  263. route: 'en/a.md',
  264. pages,
  265. repoRoot: root,
  266. repositoryRef: 'abc123',
  267. })).toBe(
  268. '[title](./reference/b.md "b.md") '
  269. + '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n',
  270. )
  271. })
  272. it('routes switchers across locales and explicit locale siblings within their locale', () => {
  273. const { root, pages } = fixture()
  274. writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
  275. writeFileSync(join(root, 'docs/b.zh.md'), '# B\n')
  276. const paired = pages.filter(page => page.source !== 'docs/a.md').map(page => (
  277. page.locale === 'root' && page.source === 'docs/b.md'
  278. ? { ...page, source: 'docs/b.zh.md', sourceAliases: ['docs/b.md'] }
  279. : page
  280. ))
  281. paired.push(
  282. {
  283. locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
  284. route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
  285. },
  286. {
  287. locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
  288. route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
  289. },
  290. )
  291. expect(rewriteMarkdown('[English](a.md) [B](b.zh.md)\n', {
  292. locale: 'root',
  293. sourcePath: 'docs/a.zh.md',
  294. route: 'guide/a.md',
  295. pages: paired,
  296. repoRoot: root,
  297. repositoryRef: 'abc123',
  298. })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
  299. expect(rewriteMarkdown('[中文](a.zh.md) [B](b.md)\n', {
  300. locale: 'en',
  301. sourcePath: 'docs/a.md',
  302. route: 'en/guide/a.md',
  303. pages: paired,
  304. repoRoot: root,
  305. repositoryRef: 'abc123',
  306. })).toBe('[中文](../../guide/a.md) [B](../reference/b.md)\n')
  307. })
  308. it('fails loud when a relative target is missing', () => {
  309. const { root, pages } = fixture()
  310. expect(() => rewriteMarkdown('[missing](missing.md)\n', {
  311. locale: 'en',
  312. sourcePath: 'docs/a.md',
  313. route: 'en/a.md',
  314. pages,
  315. repoRoot: root,
  316. repositoryRef: 'abc123',
  317. })).toThrow('links to missing path "missing.md"')
  318. })
  319. })
  320. describe('docsPages locale routes', () => {
  321. it('redirects both locale roots to their locale-relative quick-start page', () => {
  322. const homes = docsPages.filter(page => page.sidebar === null)
  323. expect(homes.map(page => page.route).sort()).toEqual(['en/index.md', 'index.md'])
  324. for (const page of homes) {
  325. const source = readFileSync(resolve(repositoryRoot, page.source), 'utf8')
  326. const projected = projectedPageContent(source, page)
  327. expect(projected).toContain('layout: false')
  328. expect(projected).toContain('http-equiv: refresh')
  329. expect(projected).toContain('content: 0; url=./guide/quickstart')
  330. expect(projected).not.toContain('# DeepSeek Harness')
  331. }
  332. })
  333. it('publishes every route in both locales and uses every available Chinese counterpart', () => {
  334. const byRoute = new Map(docsPages.map(page => [page.route, page]))
  335. for (const page of docsPages.filter(page => page.locale === 'root')) {
  336. const counterpart = byRoute.get(`en/${page.route}`)
  337. expect(counterpart, page.route).toBeDefined()
  338. expect(counterpart?.locale).toBe('en')
  339. if (page.contentLocale === 'zh-CN') {
  340. expect(page.source).toMatch(/\.zh\.md$/)
  341. expect(page.contentLocale).toBe('zh-CN')
  342. expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
  343. expect(counterpart?.contentLocale).toBe('en-US')
  344. } else {
  345. expect(counterpart?.source).toBe(page.source)
  346. expect(counterpart?.contentLocale).toBe(page.contentLocale)
  347. const chineseSource = page.source.replace(/\.md$/, '.zh.md')
  348. expect(
  349. existsSync(resolve(repositoryRoot, chineseSource)),
  350. `${page.route} has a Chinese counterpart but projects English`,
  351. ).toBe(false)
  352. }
  353. }
  354. })
  355. it('projects the audited tutorial entry links from explicit locale index pages', () => {
  356. const entries = [
  357. ['docs/user/develop/basic/config.md', '../framework/index.md'],
  358. ['docs/user/develop/basic/publish.md', '../framework/index.md'],
  359. ['docs/user/develop/basic/tool.md', './index.md'],
  360. ['docs/user/develop/basic/tool.md', '../practice/index.md'],
  361. ['docs/user/develop/framework/events.md', '../practice/index.md'],
  362. ['docs/user/develop/framework/service.md', '../practice/index.md'],
  363. ['docs/user/develop/practice/index.md', '../basic/index.md'],
  364. ['docs/user/guide/index.md', '../develop/basic/index.md'],
  365. ] as const
  366. for (const [englishSource, englishTarget] of entries) {
  367. for (const locale of ['en', 'root'] as const) {
  368. const source = locale === 'root' ? englishSource.replace(/\.md$/, '.zh.md') : englishSource
  369. const target = locale === 'root' ? englishTarget.replace(/\.md$/, '.zh.md') : englishTarget
  370. const page = docsPages.find(candidate => candidate.locale === locale && candidate.source === source)
  371. expect(page, `${locale}:${source}`).toBeDefined()
  372. expect(readFileSync(resolve(repositoryRoot, source), 'utf8')).toContain(`](${target})`)
  373. expect(rewriteMarkdown(`[Entry](${target})\n`, {
  374. locale,
  375. sourcePath: source,
  376. route: page!.route,
  377. pages: docsPages,
  378. repoRoot: repositoryRoot,
  379. repositoryRef: 'abc123',
  380. })).toBe(`[Entry](${englishTarget})\n`)
  381. }
  382. }
  383. })
  384. it('indexes every subsystem page in both sides of the folder README', () => {
  385. const pages = globSync(join(repositoryRoot, 'docs/subsystems/*.md'))
  386. .map(page => basename(page))
  387. .filter(page => !page.endsWith('.zh.md') && page !== 'README.md')
  388. .sort()
  389. expect(pages.length).toBeGreaterThan(0)
  390. for (const readme of ['README.md', 'README.zh.md']) {
  391. const rows = readFileSync(join(repositoryRoot, 'docs/subsystems', readme), 'utf8')
  392. const missing = pages.filter((page) => {
  393. const target = readme.endsWith('.zh.md') ? page.replace(/\.md$/, '.zh.md') : page
  394. return !rows.includes(`| [${page}](${target}) |`)
  395. })
  396. expect(missing, `${readme} must carry one table row per subsystem page`).toEqual([])
  397. }
  398. })
  399. it('places the shared todo fragment alias on the translated todo section', () => {
  400. const catalog = readFileSync(resolve(repositoryRoot, 'docs/tool-catalog.zh.md'), 'utf8')
  401. expect(catalog.match(/<a id="deepseek-aidsh-tool-todo"><\/a>/g)).toHaveLength(1)
  402. expect(catalog).toContain(
  403. '<a id="deepseek-aidsh-tool-todo"></a>\n\n## `@deepseek-ai/dsh-tool-todo`',
  404. )
  405. })
  406. it('projects every published subsystem page in Chinese', () => {
  407. const rootPages = docsPages.filter(page => (
  408. page.locale === 'root' && page.route.startsWith('reference/subsystems/')
  409. ))
  410. const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
  411. const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
  412. expect(translated).toHaveLength(46)
  413. expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
  414. expect(fallbacks).toEqual([])
  415. })
  416. it('publishes the Cordis core API under matching locale structures', () => {
  417. const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
  418. for (const file of files) {
  419. const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
  420. const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
  421. expect(root?.source).toBe(`docs/cordis-api/${file.replace(/\.md$/, '.zh.md')}`)
  422. expect(root?.contentLocale).toBe('zh-CN')
  423. expect(root?.section).toBe('Cordis API')
  424. expect(english?.source).toBe(`docs/cordis-api/${file}`)
  425. expect(english?.contentLocale).toBe('en-US')
  426. expect(english?.section).toBe('Cordis Core API')
  427. }
  428. })
  429. it('keeps Cordis inherited on the English fallback in both locales', () => {
  430. const pages = docsPages.filter(page => page.route.endsWith('reference/cordis-api/inherited.md'))
  431. expect(pages).toHaveLength(2)
  432. expect(pages.every(page => page.source === 'docs/cordis-api/inherited.md')).toBe(true)
  433. expect(pages.every(page => page.contentLocale === 'en-US')).toBe(true)
  434. })
  435. it('includes persistence event headings in both locale outlines', () => {
  436. const pages = docsPages.filter(page => page.route.endsWith('reference/persistence-catalog.md'))
  437. expect(pages).toHaveLength(2)
  438. expect(pages.map(page => page.source).sort()).toEqual([
  439. 'docs/persistence-catalog.md',
  440. 'docs/persistence-catalog.zh.md',
  441. ])
  442. expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
  443. })
  444. it('projects reviewed generated counterparts into root locale routes', () => {
  445. // module-graph, event-producer-consumer, and graph-atlas are paired but intentionally unpublished.
  446. const routes = [
  447. 'reference/capability-seams.md',
  448. 'reference/agent-lifecycle.md',
  449. 'reference/tool-execution-pipeline.md',
  450. 'reference/config-catalog.md',
  451. 'reference/tool-catalog.md',
  452. 'reference/persistence-catalog.md',
  453. 'reference/cordis-api/context.md',
  454. 'reference/cordis-api/events.md',
  455. 'reference/cordis-api/fiber.md',
  456. 'reference/cordis-api/registry.md',
  457. 'reference/cordis-api/service.md',
  458. ]
  459. const pages = routes.map(route => docsPages.find(page => page.route === route))
  460. expect(pages.every(page => page?.contentLocale === 'zh-CN')).toBe(true)
  461. expect(pages.every(page => page?.source.endsWith('.zh.md'))).toBe(true)
  462. })
  463. })
  464. describe('sidebar ordering', () => {
  465. it('places every section a sidebar collection owns', () => {
  466. for (const page of docsPages) {
  467. if (page.sidebar === null) continue
  468. expect(() => sectionSpec(page.locale, page.section), page.route).not.toThrow()
  469. }
  470. })
  471. it('refuses a section with no declared placement', () => {
  472. expect(() => sectionSpec('root', '数据结构'))
  473. .toThrow('Sidebar section "数据结构" has no placement in the root locale.')
  474. })
  475. it('declares placements per locale rather than in one shared list', () => {
  476. // `SDK` labels a group in both locales, so one shared list would have to
  477. // rank it against `入门` and against `Guide` at the same position.
  478. expect(sectionSpec('root', 'SDK').index).toBeGreaterThan(sectionSpec('root', '入门').index)
  479. expect(sectionSpec('en', 'SDK').index).toBeGreaterThan(sectionSpec('en', 'Guide').index)
  480. expect(() => sectionSpec('en', '入门')).toThrow()
  481. expect(() => sectionSpec('root', 'Guide')).toThrow()
  482. })
  483. it('lands every navigation item on a page the manifest publishes', () => {
  484. // The navigation bar named `/guide/` while the manifest published the guide's
  485. // first page at `guide/quickstart.md`, so the item served a 404.
  486. const collections = [
  487. ['root', 'zh-guide'], ['root', 'zh-develop'], ['root', 'zh-reference'],
  488. ['en', 'en-guide'], ['en', 'en-develop'], ['en', 'en-reference'],
  489. ] as const
  490. const published = new Set(docsPages.map(page => routeLink(page.route)))
  491. for (const [locale, collection] of collections) {
  492. expect(published, `${locale}/${collection}`).toContain(landingLink(locale, collection))
  493. }
  494. })
  495. it('collapses the subsystem groups and leaves the smaller ones open', () => {
  496. expect(sectionSpec('root', '执行与工具').collapsed).toBe(true)
  497. expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true)
  498. expect(sectionSpec('root', '概念').collapsed).toBeUndefined()
  499. })
  500. it('gives each page its own position within a section', () => {
  501. // Sidebar entries sort by order alone, so a shared value leaves the two
  502. // pages ranked by whichever manifest block happens to be concatenated
  503. // first rather than by an intent the manifest states.
  504. const taken = new Map<string, string>()
  505. const collisions: string[] = []
  506. for (const page of docsPages) {
  507. const slot = `${page.locale}/${String(page.sidebar)}/${page.section}#${page.order}`
  508. const holder = taken.get(slot)
  509. if (holder === undefined) taken.set(slot, page.label)
  510. else collisions.push(`${slot}: ${holder} / ${page.label}`)
  511. }
  512. expect(collisions).toEqual([])
  513. })
  514. })
  515. describe('addProjectionFrontmatter', () => {
  516. it('adds frontmatter to an ordinary Markdown page', () => {
  517. expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
  518. '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
  519. )
  520. })
  521. it('extends existing VitePress frontmatter', () => {
  522. expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
  523. '---\neditSource: "docs/index.md"\nlayout: home\n---\n',
  524. )
  525. })
  526. it('adds the page-specific outline depth from the publication manifest', () => {
  527. expect(addProjectionFrontmatter('# Catalog\n', {
  528. source: 'docs/catalog.md',
  529. outline: [2, 4],
  530. })).toBe(
  531. '---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
  532. )
  533. })
  534. })
  535. describe('projectedPageContent', () => {
  536. const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
  537. locale: 'root',
  538. contentLocale: 'zh-CN',
  539. source: 'docs/index.zh.md',
  540. route: 'index.md',
  541. label: 'Home',
  542. sidebar,
  543. section: 'Home',
  544. order: 0,
  545. })
  546. it('omits the source-only body from locale home pages', () => {
  547. expect(projectedPageContent(
  548. '---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n\n# Harness\n\n[English](index.md) | 中文\n',
  549. page(null),
  550. )).toBe('---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n')
  551. })
  552. it('keeps the full body for ordinary pages', () => {
  553. const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
  554. expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
  555. })
  556. it('drops the language switcher the navigation bar already offers', () => {
  557. expect(projectedPageContent('# Guide\n\nEnglish | [中文](./en/guide)\n\nBody.\n', page('zh-guide')))
  558. .toBe('# Guide\n\nBody.\n')
  559. expect(projectedPageContent('# 指南\n\n[English](./en/guide) | 中文\n\n正文。\n', page('zh-guide')))
  560. .toBe('# 指南\n\n正文。\n')
  561. })
  562. it('drops the repository badge every page links from its footer', () => {
  563. const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)'
  564. expect(projectedPageContent(`# Guide\n\nBody.\n\n${badge}\n`, page('zh-guide')))
  565. .toBe('# Guide\n\nBody.\n')
  566. })
  567. it('keeps a switcher-shaped line that is not the page header', () => {
  568. // A tutorial showing the convention must still render the example.
  569. const sample = '# Guide\n\nA\n\nB\n\nC\n\nD\n\nE\n\nEnglish | [中文](./x)\n'
  570. expect(projectedPageContent(sample, page('zh-guide'))).toBe(sample)
  571. })
  572. it('rejects a locale home source without frontmatter', () => {
  573. expect(() => projectedPageContent('# Harness\n', page(null)))
  574. .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
  575. })
  576. })
  577. describe('rawMarkdownPageContent', () => {
  578. it('keeps the home body the rendered site omits and drops the VitePress frontmatter', () => {
  579. expect(rawMarkdownPageContent(
  580. '---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n\n# Harness\n\nEnglish | [中文](./index.md)\n\nBody.\n',
  581. 'docs/user/index.zh.md',
  582. )).toBe('# Harness\n\nBody.\n')
  583. })
  584. it('drops the language switcher and repository badge like the rendered site', () => {
  585. const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)'
  586. expect(rawMarkdownPageContent(`# Guide\n\nEnglish | [中文](./x)\n\nBody.\n\n${badge}\n`, 'docs/guide.md'))
  587. .toBe('# Guide\n\nBody.\n')
  588. })
  589. it('rejects unclosed frontmatter and names the page', () => {
  590. // The twin pass is the first place an ordinary page's frontmatter is
  591. // parsed, so an anonymous error would leave 168 routes to search.
  592. expect(() => rawMarkdownPageContent('---\nlayout: false\n', 'docs/broken.md'))
  593. .toThrow('project-doc-site: "docs/broken.md" has unclosed YAML frontmatter')
  594. })
  595. })
  596. describe('emitRawMarkdownPages', () => {
  597. function mirrorDir(): string {
  598. const out = mkdtempSync(join(tmpdir(), 'dsh-doc-mirror-'))
  599. roots.push(out)
  600. return out
  601. }
  602. it('writes every route with rewritten links, placed images, and no projection frontmatter', () => {
  603. const { root, pages } = fixture()
  604. writeFileSync(join(root, 'docs/a.md'), '[B](b.md) ![logo](../packages/logo.svg)\n')
  605. const out = mirrorDir()
  606. // The real path, because image placement proves containment via realpath.
  607. emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' })
  608. expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('[B](./reference-root/b.md) ![logo](./logo.svg)\n')
  609. expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('[B](./reference/b.md) ![logo](./logo.svg)\n')
  610. expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('# B\n')
  611. expect(existsSync(join(out, 'logo.svg'))).toBe(true)
  612. expect(existsSync(join(out, 'en/logo.svg'))).toBe(true)
  613. })
  614. it('emits the full body of a locale home page', () => {
  615. const { root, pages } = fixture()
  616. writeFileSync(join(root, 'docs/home.md'), '---\nlayout: false\n---\n\n# Home\n\n[A](a.md)\n')
  617. pages.push({
  618. locale: 'root', contentLocale: 'zh-CN', source: 'docs/home.md', route: 'index.md',
  619. label: 'Home', sidebar: null, section: 'Home', order: 0,
  620. })
  621. const out = mirrorDir()
  622. emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
  623. expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('# Home\n\n[A](./a.md)\n')
  624. })
  625. it('emits a parent-level alias for an index route with links recomputed', () => {
  626. // A copied alias would carry the index page's relative links one directory
  627. // too high, so the alias is its own projection over the alias route.
  628. const { root, pages } = fixture()
  629. writeFileSync(join(root, 'docs/c.md'), '# C\n\n[A](a.md)\n')
  630. pages.push({
  631. locale: 'root', contentLocale: 'en-US', source: 'docs/c.md', route: 'guide/index.md',
  632. label: 'C', sidebar: 'zh-guide', section: 'Test', order: 3,
  633. })
  634. const out = mirrorDir()
  635. emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
  636. expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('# C\n\n[A](../a.md)\n')
  637. expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('# C\n\n[A](./a.md)\n')
  638. })
  639. it('refuses to overwrite a file the build already carries', () => {
  640. // The twin pass writes into a populated build directory, and VitePress has
  641. // already copied `website/public/` there; a page image sharing one of
  642. // those names must fail loud instead of silently replacing the site file.
  643. const { root, pages } = fixture()
  644. writeFileSync(join(root, 'docs/a.md'), '![logo](../packages/logo.svg)\n')
  645. const out = mirrorDir()
  646. writeFileSync(join(out, 'logo.svg'), 'public copy\n')
  647. expect(() => {
  648. emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' })
  649. }).toThrow('would overwrite')
  650. expect(readFileSync(join(out, 'logo.svg'), 'utf8')).toBe('public copy\n')
  651. })
  652. })
  653. describe('rawMarkdownFiles', () => {
  654. it('lists every route plus a parent alias per index route', () => {
  655. const files = rawMarkdownFiles()
  656. for (const page of docsPages) expect(files).toContain(page.route)
  657. expect(files).toContain('reference.md')
  658. expect(files).toContain('en/reference.md')
  659. expect(files).toContain('en.md')
  660. // The root home has no parent to alias into; `/` is documented as `/index.md`.
  661. expect(files).not.toContain('.md')
  662. expect(new Set(files).size).toBe(files.length)
  663. })
  664. })
  665. describe('raw Markdown projection of the published manifest', () => {
  666. let mirror: string
  667. // Coverage instrumentation on a loaded CI runner stretches the full-manifest
  668. // emission and the 181-file link walk past vitest's 5s default.
  669. beforeAll(() => {
  670. mirror = mkdtempSync(join(tmpdir(), 'dsh-doc-mirror-real-'))
  671. emitRawMarkdownPages(mirror, { pages: docsPages, repoRoot: repositoryRoot, repositoryRef: 'master' })
  672. }, 60_000)
  673. afterAll(() => {
  674. rmSync(mirror, { recursive: true, force: true })
  675. })
  676. it('emits every published route and every index alias', () => {
  677. for (const file of rawMarkdownFiles()) {
  678. expect(existsSync(join(mirror, file)), file).toBe(true)
  679. }
  680. })
  681. it('emits home pages with their bodies instead of the frontmatter stub', () => {
  682. for (const route of ['index.md', 'en/index.md']) {
  683. const home = readFileSync(join(mirror, route), 'utf8')
  684. expect(home.startsWith('---'), route).toBe(false)
  685. expect(home, route).toContain('# DeepSeek Harness')
  686. }
  687. })
  688. it('resolves every relative link inside the emitted tree', { timeout: 60_000 }, () => {
  689. // Raw pages are read outside the site, so a relative target that only the
  690. // rendered site serves would strand every agent following it.
  691. const broken: string[] = []
  692. for (const file of globSync('**/*.md', { cwd: mirror }).sort()) {
  693. for (const target of relativeTargets(readFileSync(join(mirror, file), 'utf8'))) {
  694. if (!existsSync(resolve(mirror, dirname(file), target))) broken.push(`${file}: ${target}`)
  695. }
  696. }
  697. expect(broken).toEqual([])
  698. })
  699. })
  700. function relativeTargets(markdown: string): string[] {
  701. const tree = fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  702. const targets: string[] = []
  703. const visit = (node: Nodes): void => {
  704. if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
  705. const external = node.url.startsWith('#')
  706. || node.url.startsWith('/')
  707. || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(node.url)
  708. const path = node.url.split(/[?#]/)[0] ?? ''
  709. if (!external && path !== '') targets.push(decodeURIComponent(path))
  710. }
  711. if ('children' in node) {
  712. for (const child of node.children) visit(child)
  713. }
  714. }
  715. visit(tree)
  716. return targets
  717. }
  718. describe('llmsTxt', () => {
  719. const site = { base: '/x/', title: 'DeepSeek Harness', description: '插件化 SDK' }
  720. it('lists every sidebar page as a base-prefixed raw-Markdown link', () => {
  721. const text = llmsTxt(site)
  722. for (const page of docsPages) {
  723. if (page.sidebar === null) expect(text, page.route).not.toContain(`](/x/${page.route})`)
  724. else expect(text, page.route).toContain(`- [${page.label}](/x/${page.route}): ${page.section}`)
  725. }
  726. })
  727. it('groups the two locale trees under their own headings', () => {
  728. const text = llmsTxt(site)
  729. expect(text.indexOf('## 简体中文')).toBeGreaterThan(-1)
  730. expect(text.indexOf('## English')).toBeGreaterThan(text.indexOf('## 简体中文'))
  731. })
  732. it('carries the site identity and the raw-Markdown convention', () => {
  733. const text = llmsTxt(site)
  734. expect(text.startsWith('# DeepSeek Harness\n')).toBe(true)
  735. expect(text).toContain('> 插件化 SDK')
  736. expect(text).toMatch(/`\.md`/)
  737. })
  738. })
  739. describe('rawMarkdownRoute', () => {
  740. it('projects one published route on demand', () => {
  741. const { root, pages } = fixture()
  742. writeFileSync(join(root, 'docs/a.md'), '# A\n\n[B](b.md)\n')
  743. expect(rawMarkdownRoute('en/a.md', { pages, repoRoot: root, repositoryRef: 'abc123' }))
  744. .toBe('# A\n\n[B](./reference/b.md)\n')
  745. })
  746. it('returns undefined for a path the manifest does not publish', () => {
  747. const { root, pages } = fixture()
  748. expect(rawMarkdownRoute('en/missing.md', { pages, repoRoot: root, repositoryRef: 'abc123' })).toBeUndefined()
  749. })
  750. })