project-doc-site.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  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, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { basename, join, resolve } from 'node:path'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
  8. import {
  9. addProjectionFrontmatter, projectedPageContent, publishableImage, resolveRepositoryRef, rewriteMarkdown,
  10. } from './project-doc-site.ts'
  11. const roots: string[] = []
  12. const repositoryRoot = resolve(import.meta.dirname, '..')
  13. function unexpectedWebsiteMarkdown(files: readonly string[]): string[] {
  14. return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort()
  15. }
  16. afterEach(() => {
  17. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  18. })
  19. function fixture(): { root: string; pages: DocsPage[] } {
  20. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
  21. roots.push(root)
  22. mkdirSync(join(root, 'docs'), { recursive: true })
  23. mkdirSync(join(root, 'packages'), { recursive: true })
  24. writeFileSync(join(root, 'docs/a.md'), '# A\n')
  25. writeFileSync(join(root, 'docs/b.md'), '# B\n')
  26. writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
  27. writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
  28. writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
  29. return {
  30. root,
  31. pages: [
  32. { locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
  33. { locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
  34. { locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
  35. { locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
  36. ],
  37. }
  38. }
  39. describe('website source layout', () => {
  40. it('rejects Markdown outside the subtree instructions', () => {
  41. expect(unexpectedWebsiteMarkdown([
  42. 'website/AGENTS.md',
  43. 'website/docs.ts',
  44. 'website/zh-CN/api/harness/service.md',
  45. ])).toEqual(['website/zh-CN/api/harness/service.md'])
  46. })
  47. it('contains no tracked or unignored documentation copies', () => {
  48. const files = execFileSync(
  49. 'git',
  50. ['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'],
  51. { cwd: repositoryRoot, encoding: 'utf8' },
  52. ).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file)))
  53. expect(
  54. unexpectedWebsiteMarkdown(files),
  55. 'Keep canonical Markdown under docs/ and publish it through website/docs.ts.',
  56. ).toEqual([])
  57. })
  58. })
  59. describe('publishableImage', () => {
  60. it('accepts a regular file inside the repository', () => {
  61. const { root } = fixture()
  62. const real = realpathSync(join(root, 'packages/logo.svg'))
  63. expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real)
  64. })
  65. it('refuses a target whose real path escapes the repository', () => {
  66. // Publication copies the bytes onto the site, so a reference reaching a
  67. // build-machine file must not be treated as an image the repository owns.
  68. const { root } = fixture()
  69. const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-'))
  70. roots.push(outside)
  71. writeFileSync(join(outside, 'secret.png'), 'not really a png\n')
  72. symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png'))
  73. expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined()
  74. expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined()
  75. })
  76. it('refuses a directory', () => {
  77. const { root } = fixture()
  78. expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined()
  79. })
  80. })
  81. describe('resolveRepositoryRef', () => {
  82. it('defaults to public master instead of a private workflow SHA', () => {
  83. expect(resolveRepositoryRef({ GITHUB_SHA: 'private-sha' })).toBe('master')
  84. })
  85. it('accepts an explicit public repository ref', () => {
  86. expect(resolveRepositoryRef({ DOCS_REPOSITORY_REF: 'public-sha' })).toBe('public-sha')
  87. })
  88. })
  89. describe('rewriteMarkdown', () => {
  90. it('maps published pages and pins unpublished source links', () => {
  91. const { root, pages } = fixture()
  92. const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
  93. expect(rewriteMarkdown(source, {
  94. locale: 'en',
  95. sourcePath: 'docs/a.md',
  96. route: 'en/a.md',
  97. pages,
  98. repoRoot: root,
  99. repositoryRef: 'abc123',
  100. })).toBe(
  101. '[B](./reference/b.md#part) '
  102. + '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
  103. + '[web](https://example.com)\n',
  104. )
  105. })
  106. it('selects the published target in the current site locale', () => {
  107. const { root, pages } = fixture()
  108. expect(rewriteMarkdown('[B](b.md)\n', {
  109. locale: 'root',
  110. sourcePath: 'docs/a.md',
  111. route: 'a.md',
  112. pages,
  113. repoRoot: root,
  114. repositoryRef: 'abc123',
  115. })).toBe('[B](./reference-root/b.md)\n')
  116. })
  117. it('uses raw GitHub content for unpublished images when nothing places them', () => {
  118. const { root, pages } = fixture()
  119. expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
  120. locale: 'en',
  121. sourcePath: 'docs/a.md',
  122. route: 'en/a.md',
  123. pages,
  124. repoRoot: root,
  125. repositoryRef: 'abc123',
  126. })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/abc123/packages/logo.svg)\n')
  127. })
  128. it('hands an image to the placer and uses the URL it returns', () => {
  129. // A raw GitHub URL cannot serve a private repository, so the site build
  130. // carries images itself; the placer is what puts them there. The stand-in
  131. // derives its URL the way the real one does, so a placer that stopped
  132. // returning the basename would fail here rather than pass on a constant.
  133. const { root, pages } = fixture()
  134. const placed: string[] = []
  135. expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
  136. locale: 'en',
  137. sourcePath: 'docs/a.md',
  138. route: 'en/a.md',
  139. pages,
  140. repoRoot: root,
  141. repositoryRef: 'abc123',
  142. placeImage: (absPath) => {
  143. const name = basename(absPath)
  144. placed.push(name)
  145. return `./${name}`
  146. },
  147. })).toBe('![logo](./logo.svg)\n')
  148. expect(placed).toEqual(['logo.svg'])
  149. })
  150. it('keeps a placed image\u2019s query or fragment', () => {
  151. // An SVG view fragment and a Vite query both change what the reference
  152. // means, and the GitHub branch has always carried them.
  153. const { root, pages } = fixture()
  154. expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', {
  155. locale: 'en',
  156. sourcePath: 'docs/a.md',
  157. route: 'en/a.md',
  158. pages,
  159. repoRoot: root,
  160. repositoryRef: 'abc123',
  161. placeImage: absPath => `./${basename(absPath)}`,
  162. })).toBe('![logo](./logo.svg#view)\n')
  163. })
  164. it('leaves a published page link to the route even when a placer exists', () => {
  165. const { root, pages } = fixture()
  166. expect(rewriteMarkdown('[B](b.md)\n', {
  167. locale: 'en',
  168. sourcePath: 'docs/a.md',
  169. route: 'en/a.md',
  170. pages,
  171. repoRoot: root,
  172. repositoryRef: 'abc123',
  173. placeImage: () => { throw new Error('a page link must not be placed as an asset') },
  174. })).toBe('[B](./reference/b.md)\n')
  175. })
  176. it('does not rewrite Markdown-looking text inside code fences', () => {
  177. const { root, pages } = fixture()
  178. const source = '```md\n[B](b.md)\n```\n'
  179. expect(rewriteMarkdown(source, {
  180. locale: 'en',
  181. sourcePath: 'docs/a.md',
  182. route: 'en/a.md',
  183. pages,
  184. repoRoot: root,
  185. repositoryRef: 'abc123',
  186. })).toBe(source)
  187. })
  188. it('replaces the destination token without changing repeated titles or escapes', () => {
  189. const { root, pages } = fixture()
  190. const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
  191. expect(rewriteMarkdown(source, {
  192. locale: 'en',
  193. sourcePath: 'docs/a.md',
  194. route: 'en/a.md',
  195. pages,
  196. repoRoot: root,
  197. repositoryRef: 'abc123',
  198. })).toBe(
  199. '[title](./reference/b.md "b.md") '
  200. + '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n',
  201. )
  202. })
  203. it('routes switchers across locales and explicit locale siblings within their locale', () => {
  204. const { root, pages } = fixture()
  205. writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
  206. writeFileSync(join(root, 'docs/b.zh.md'), '# B\n')
  207. const paired = pages.filter(page => page.source !== 'docs/a.md').map(page => (
  208. page.locale === 'root' && page.source === 'docs/b.md'
  209. ? { ...page, source: 'docs/b.zh.md', sourceAliases: ['docs/b.md'] }
  210. : page
  211. ))
  212. paired.push(
  213. {
  214. locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
  215. route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
  216. },
  217. {
  218. locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
  219. route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
  220. },
  221. )
  222. expect(rewriteMarkdown('[English](a.md) [B](b.zh.md)\n', {
  223. locale: 'root',
  224. sourcePath: 'docs/a.zh.md',
  225. route: 'guide/a.md',
  226. pages: paired,
  227. repoRoot: root,
  228. repositoryRef: 'abc123',
  229. })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
  230. expect(rewriteMarkdown('[中文](a.zh.md) [B](b.md)\n', {
  231. locale: 'en',
  232. sourcePath: 'docs/a.md',
  233. route: 'en/guide/a.md',
  234. pages: paired,
  235. repoRoot: root,
  236. repositoryRef: 'abc123',
  237. })).toBe('[中文](../../guide/a.md) [B](../reference/b.md)\n')
  238. })
  239. it('fails loud when a relative target is missing', () => {
  240. const { root, pages } = fixture()
  241. expect(() => rewriteMarkdown('[missing](missing.md)\n', {
  242. locale: 'en',
  243. sourcePath: 'docs/a.md',
  244. route: 'en/a.md',
  245. pages,
  246. repoRoot: root,
  247. repositoryRef: 'abc123',
  248. })).toThrow('links to missing path "missing.md"')
  249. })
  250. })
  251. describe('docsPages locale routes', () => {
  252. it('redirects both locale roots to their locale-relative quick-start page', () => {
  253. const homes = docsPages.filter(page => page.sidebar === null)
  254. expect(homes.map(page => page.route).sort()).toEqual(['en/index.md', 'index.md'])
  255. for (const page of homes) {
  256. const source = readFileSync(resolve(repositoryRoot, page.source), 'utf8')
  257. const projected = projectedPageContent(source, page)
  258. expect(projected).toContain('layout: false')
  259. expect(projected).toContain('http-equiv: refresh')
  260. expect(projected).toContain('content: 0; url=./guide/quickstart')
  261. expect(projected).not.toContain('# DeepSeek Harness')
  262. }
  263. })
  264. it('publishes every route in both locales and uses every available Chinese counterpart', () => {
  265. const byRoute = new Map(docsPages.map(page => [page.route, page]))
  266. for (const page of docsPages.filter(page => page.locale === 'root')) {
  267. const counterpart = byRoute.get(`en/${page.route}`)
  268. expect(counterpart, page.route).toBeDefined()
  269. expect(counterpart?.locale).toBe('en')
  270. if (page.contentLocale === 'zh-CN') {
  271. expect(page.source).toMatch(/\.zh\.md$/)
  272. expect(page.contentLocale).toBe('zh-CN')
  273. expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
  274. expect(counterpart?.contentLocale).toBe('en-US')
  275. } else {
  276. expect(counterpart?.source).toBe(page.source)
  277. expect(counterpart?.contentLocale).toBe(page.contentLocale)
  278. const chineseSource = page.source.replace(/\.md$/, '.zh.md')
  279. expect(
  280. existsSync(resolve(repositoryRoot, chineseSource)),
  281. `${page.route} has a Chinese counterpart but projects English`,
  282. ).toBe(false)
  283. }
  284. }
  285. })
  286. it('projects the audited tutorial entry links from explicit locale index pages', () => {
  287. const entries = [
  288. ['docs/user/develop/basic/config.md', '../framework/index.md'],
  289. ['docs/user/develop/basic/publish.md', '../framework/index.md'],
  290. ['docs/user/develop/basic/tool.md', './index.md'],
  291. ['docs/user/develop/basic/tool.md', '../practice/index.md'],
  292. ['docs/user/develop/framework/events.md', '../practice/index.md'],
  293. ['docs/user/develop/framework/service.md', '../practice/index.md'],
  294. ['docs/user/develop/practice/index.md', '../basic/index.md'],
  295. ['docs/user/guide/index.md', '../develop/basic/index.md'],
  296. ] as const
  297. for (const [englishSource, englishTarget] of entries) {
  298. for (const locale of ['en', 'root'] as const) {
  299. const source = locale === 'root' ? englishSource.replace(/\.md$/, '.zh.md') : englishSource
  300. const target = locale === 'root' ? englishTarget.replace(/\.md$/, '.zh.md') : englishTarget
  301. const page = docsPages.find(candidate => candidate.locale === locale && candidate.source === source)
  302. expect(page, `${locale}:${source}`).toBeDefined()
  303. expect(readFileSync(resolve(repositoryRoot, source), 'utf8')).toContain(`](${target})`)
  304. expect(rewriteMarkdown(`[Entry](${target})\n`, {
  305. locale,
  306. sourcePath: source,
  307. route: page!.route,
  308. pages: docsPages,
  309. repoRoot: repositoryRoot,
  310. repositoryRef: 'abc123',
  311. })).toBe(`[Entry](${englishTarget})\n`)
  312. }
  313. }
  314. })
  315. it('indexes every subsystem page in both sides of the folder README', () => {
  316. const pages = globSync(join(repositoryRoot, 'docs/subsystems/*.md'))
  317. .map(page => basename(page))
  318. .filter(page => !page.endsWith('.zh.md') && page !== 'README.md')
  319. .sort()
  320. expect(pages.length).toBeGreaterThan(0)
  321. for (const readme of ['README.md', 'README.zh.md']) {
  322. const rows = readFileSync(join(repositoryRoot, 'docs/subsystems', readme), 'utf8')
  323. const missing = pages.filter((page) => {
  324. const target = readme.endsWith('.zh.md') ? page.replace(/\.md$/, '.zh.md') : page
  325. return !rows.includes(`| [${page}](${target}) |`)
  326. })
  327. expect(missing, `${readme} must carry one table row per subsystem page`).toEqual([])
  328. }
  329. })
  330. it('places the shared todo fragment alias on the translated todo section', () => {
  331. const catalog = readFileSync(resolve(repositoryRoot, 'docs/tool-catalog.zh.md'), 'utf8')
  332. expect(catalog.match(/<a id="deepseek-aidsh-tool-todo"><\/a>/g)).toHaveLength(1)
  333. expect(catalog).toContain(
  334. '<a id="deepseek-aidsh-tool-todo"></a>\n\n## `@deepseek-ai/dsh-tool-todo`',
  335. )
  336. })
  337. it('projects every published subsystem page in Chinese', () => {
  338. const rootPages = docsPages.filter(page => (
  339. page.locale === 'root' && page.route.startsWith('reference/subsystems/')
  340. ))
  341. const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
  342. const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
  343. expect(translated).toHaveLength(43)
  344. expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
  345. expect(fallbacks).toEqual([])
  346. })
  347. it('publishes the Cordis core API under matching locale structures', () => {
  348. const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
  349. for (const file of files) {
  350. const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
  351. const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
  352. expect(root?.source).toBe(`docs/cordis-api/${file.replace(/\.md$/, '.zh.md')}`)
  353. expect(root?.contentLocale).toBe('zh-CN')
  354. expect(root?.section).toBe('Cordis API')
  355. expect(english?.source).toBe(`docs/cordis-api/${file}`)
  356. expect(english?.contentLocale).toBe('en-US')
  357. expect(english?.section).toBe('Cordis Core API')
  358. }
  359. })
  360. it('keeps Cordis inherited on the English fallback in both locales', () => {
  361. const pages = docsPages.filter(page => page.route.endsWith('reference/cordis-api/inherited.md'))
  362. expect(pages).toHaveLength(2)
  363. expect(pages.every(page => page.source === 'docs/cordis-api/inherited.md')).toBe(true)
  364. expect(pages.every(page => page.contentLocale === 'en-US')).toBe(true)
  365. })
  366. it('includes persistence event headings in both locale outlines', () => {
  367. const pages = docsPages.filter(page => page.route.endsWith('reference/persistence-catalog.md'))
  368. expect(pages).toHaveLength(2)
  369. expect(pages.map(page => page.source).sort()).toEqual([
  370. 'docs/persistence-catalog.md',
  371. 'docs/persistence-catalog.zh.md',
  372. ])
  373. expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
  374. })
  375. it('projects reviewed generated counterparts into root locale routes', () => {
  376. // module-graph, event-producer-consumer, and graph-atlas are paired but intentionally unpublished.
  377. const routes = [
  378. 'reference/capability-seams.md',
  379. 'reference/agent-lifecycle.md',
  380. 'reference/tool-execution-pipeline.md',
  381. 'reference/config-catalog.md',
  382. 'reference/tool-catalog.md',
  383. 'reference/persistence-catalog.md',
  384. 'reference/cordis-api/context.md',
  385. 'reference/cordis-api/events.md',
  386. 'reference/cordis-api/fiber.md',
  387. 'reference/cordis-api/registry.md',
  388. 'reference/cordis-api/service.md',
  389. ]
  390. const pages = routes.map(route => docsPages.find(page => page.route === route))
  391. expect(pages.every(page => page?.contentLocale === 'zh-CN')).toBe(true)
  392. expect(pages.every(page => page?.source.endsWith('.zh.md'))).toBe(true)
  393. })
  394. })
  395. describe('sidebar ordering', () => {
  396. it('places every section a sidebar collection owns', () => {
  397. for (const page of docsPages) {
  398. if (page.sidebar === null) continue
  399. expect(() => sectionSpec(page.locale, page.section), page.route).not.toThrow()
  400. }
  401. })
  402. it('refuses a section with no declared placement', () => {
  403. expect(() => sectionSpec('root', '数据结构'))
  404. .toThrow('Sidebar section "数据结构" has no placement in the root locale.')
  405. })
  406. it('declares placements per locale rather than in one shared list', () => {
  407. // `SDK` labels a group in both locales, so one shared list would have to
  408. // rank it against `入门` and against `Guide` at the same position.
  409. expect(sectionSpec('root', 'SDK').index).toBeGreaterThan(sectionSpec('root', '入门').index)
  410. expect(sectionSpec('en', 'SDK').index).toBeGreaterThan(sectionSpec('en', 'Guide').index)
  411. expect(() => sectionSpec('en', '入门')).toThrow()
  412. expect(() => sectionSpec('root', 'Guide')).toThrow()
  413. })
  414. it('lands every navigation item on a page the manifest publishes', () => {
  415. // The navigation bar named `/guide/` while the manifest published the guide's
  416. // first page at `guide/quickstart.md`, so the item served a 404.
  417. const collections = [
  418. ['root', 'zh-guide'], ['root', 'zh-develop'], ['root', 'zh-reference'],
  419. ['en', 'en-guide'], ['en', 'en-develop'], ['en', 'en-reference'],
  420. ] as const
  421. const published = new Set(docsPages.map(page => routeLink(page.route)))
  422. for (const [locale, collection] of collections) {
  423. expect(published, `${locale}/${collection}`).toContain(landingLink(locale, collection))
  424. }
  425. })
  426. it('collapses the subsystem groups and leaves the smaller ones open', () => {
  427. expect(sectionSpec('root', '执行与工具').collapsed).toBe(true)
  428. expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true)
  429. expect(sectionSpec('root', '概念').collapsed).toBeUndefined()
  430. })
  431. it('gives each page its own position within a section', () => {
  432. // Sidebar entries sort by order alone, so a shared value leaves the two
  433. // pages ranked by whichever manifest block happens to be concatenated
  434. // first rather than by an intent the manifest states.
  435. const taken = new Map<string, string>()
  436. const collisions: string[] = []
  437. for (const page of docsPages) {
  438. const slot = `${page.locale}/${String(page.sidebar)}/${page.section}#${page.order}`
  439. const holder = taken.get(slot)
  440. if (holder === undefined) taken.set(slot, page.label)
  441. else collisions.push(`${slot}: ${holder} / ${page.label}`)
  442. }
  443. expect(collisions).toEqual([])
  444. })
  445. })
  446. describe('addProjectionFrontmatter', () => {
  447. it('adds frontmatter to an ordinary Markdown page', () => {
  448. expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
  449. '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
  450. )
  451. })
  452. it('extends existing VitePress frontmatter', () => {
  453. expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
  454. '---\neditSource: "docs/index.md"\nlayout: home\n---\n',
  455. )
  456. })
  457. it('adds the page-specific outline depth from the publication manifest', () => {
  458. expect(addProjectionFrontmatter('# Catalog\n', {
  459. source: 'docs/catalog.md',
  460. outline: [2, 4],
  461. })).toBe(
  462. '---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
  463. )
  464. })
  465. })
  466. describe('projectedPageContent', () => {
  467. const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
  468. locale: 'root',
  469. contentLocale: 'zh-CN',
  470. source: 'docs/index.zh.md',
  471. route: 'index.md',
  472. label: 'Home',
  473. sidebar,
  474. section: 'Home',
  475. order: 0,
  476. })
  477. it('omits the source-only body from locale home pages', () => {
  478. expect(projectedPageContent(
  479. '---\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',
  480. page(null),
  481. )).toBe('---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n')
  482. })
  483. it('keeps the full body for ordinary pages', () => {
  484. const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
  485. expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
  486. })
  487. it('drops the language switcher the navigation bar already offers', () => {
  488. expect(projectedPageContent('# Guide\n\nEnglish | [中文](./en/guide)\n\nBody.\n', page('zh-guide')))
  489. .toBe('# Guide\n\nBody.\n')
  490. expect(projectedPageContent('# 指南\n\n[English](./en/guide) | 中文\n\n正文。\n', page('zh-guide')))
  491. .toBe('# 指南\n\n正文。\n')
  492. })
  493. it('drops the repository badge every page links from its footer', () => {
  494. const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)'
  495. expect(projectedPageContent(`# Guide\n\nBody.\n\n${badge}\n`, page('zh-guide')))
  496. .toBe('# Guide\n\nBody.\n')
  497. })
  498. it('keeps a switcher-shaped line that is not the page header', () => {
  499. // A tutorial showing the convention must still render the example.
  500. const sample = '# Guide\n\nA\n\nB\n\nC\n\nD\n\nE\n\nEnglish | [中文](./x)\n'
  501. expect(projectedPageContent(sample, page('zh-guide'))).toBe(sample)
  502. })
  503. it('rejects a locale home source without frontmatter', () => {
  504. expect(() => projectedPageContent('# Harness\n', page(null)))
  505. .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
  506. })
  507. })