project-doc-site.spec.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. /** Tests for the documentation website projection adapter. */
  2. import { execFileSync } from 'node:child_process'
  3. import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join, resolve } from 'node:path'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { docsPages, type DocsPage } from '../website/docs.ts'
  8. import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
  9. const roots: string[] = []
  10. const repositoryRoot = resolve(import.meta.dirname, '..')
  11. function unexpectedWebsiteMarkdown(files: readonly string[]): string[] {
  12. return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort()
  13. }
  14. afterEach(() => {
  15. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  16. })
  17. function fixture(): { root: string; pages: DocsPage[] } {
  18. const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
  19. roots.push(root)
  20. mkdirSync(join(root, 'docs'), { recursive: true })
  21. mkdirSync(join(root, 'packages'), { recursive: true })
  22. writeFileSync(join(root, 'docs/a.md'), '# A\n')
  23. writeFileSync(join(root, 'docs/b.md'), '# B\n')
  24. writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
  25. writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
  26. writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
  27. return {
  28. root,
  29. pages: [
  30. { locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
  31. { locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
  32. { locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
  33. { locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
  34. ],
  35. }
  36. }
  37. describe('website source layout', () => {
  38. it('rejects Markdown outside the subtree instructions', () => {
  39. expect(unexpectedWebsiteMarkdown([
  40. 'website/AGENTS.md',
  41. 'website/docs.ts',
  42. 'website/zh-CN/api/harness/service.md',
  43. ])).toEqual(['website/zh-CN/api/harness/service.md'])
  44. })
  45. it('contains no tracked or unignored documentation copies', () => {
  46. const files = execFileSync(
  47. 'git',
  48. ['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'],
  49. { cwd: repositoryRoot, encoding: 'utf8' },
  50. ).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file)))
  51. expect(
  52. unexpectedWebsiteMarkdown(files),
  53. 'Keep canonical Markdown under docs/ and publish it through website/docs.ts.',
  54. ).toEqual([])
  55. })
  56. })
  57. describe('rewriteMarkdown', () => {
  58. it('maps published pages and pins unpublished source links', () => {
  59. const { root, pages } = fixture()
  60. const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
  61. expect(rewriteMarkdown(source, {
  62. locale: 'en',
  63. sourcePath: 'docs/a.md',
  64. route: 'en/a.md',
  65. pages,
  66. repoRoot: root,
  67. repositoryRef: 'abc123',
  68. })).toBe(
  69. '[B](./reference/b.md#part) '
  70. + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
  71. + '[web](https://example.com)\n',
  72. )
  73. })
  74. it('selects the published target in the current site locale', () => {
  75. const { root, pages } = fixture()
  76. expect(rewriteMarkdown('[B](b.md)\n', {
  77. locale: 'root',
  78. sourcePath: 'docs/a.md',
  79. route: 'a.md',
  80. pages,
  81. repoRoot: root,
  82. repositoryRef: 'abc123',
  83. })).toBe('[B](./reference-root/b.md)\n')
  84. })
  85. it('uses raw GitHub content for unpublished images', () => {
  86. const { root, pages } = fixture()
  87. expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
  88. locale: 'en',
  89. sourcePath: 'docs/a.md',
  90. route: 'en/a.md',
  91. pages,
  92. repoRoot: root,
  93. repositoryRef: 'abc123',
  94. })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n')
  95. })
  96. it('does not rewrite Markdown-looking text inside code fences', () => {
  97. const { root, pages } = fixture()
  98. const source = '```md\n[B](b.md)\n```\n'
  99. expect(rewriteMarkdown(source, {
  100. locale: 'en',
  101. sourcePath: 'docs/a.md',
  102. route: 'en/a.md',
  103. pages,
  104. repoRoot: root,
  105. repositoryRef: 'abc123',
  106. })).toBe(source)
  107. })
  108. it('replaces the destination token without changing repeated titles or escapes', () => {
  109. const { root, pages } = fixture()
  110. const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
  111. expect(rewriteMarkdown(source, {
  112. locale: 'en',
  113. sourcePath: 'docs/a.md',
  114. route: 'en/a.md',
  115. pages,
  116. repoRoot: root,
  117. repositoryRef: 'abc123',
  118. })).toBe(
  119. '[title](./reference/b.md "b.md") '
  120. + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
  121. )
  122. })
  123. it('routes a pair switcher across locales while ordinary links stay in locale', () => {
  124. const { root, pages } = fixture()
  125. writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
  126. const paired = pages.filter(page => page.source !== 'docs/a.md')
  127. paired.push(
  128. {
  129. locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
  130. route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
  131. },
  132. {
  133. locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
  134. route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
  135. },
  136. )
  137. expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', {
  138. locale: 'root',
  139. sourcePath: 'docs/a.zh.md',
  140. route: 'guide/a.md',
  141. pages: paired,
  142. repoRoot: root,
  143. repositoryRef: 'abc123',
  144. })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
  145. })
  146. it('fails loud when a relative target is missing', () => {
  147. const { root, pages } = fixture()
  148. expect(() => rewriteMarkdown('[missing](missing.md)\n', {
  149. locale: 'en',
  150. sourcePath: 'docs/a.md',
  151. route: 'en/a.md',
  152. pages,
  153. repoRoot: root,
  154. repositoryRef: 'abc123',
  155. })).toThrow('links to missing path "missing.md"')
  156. })
  157. })
  158. describe('docsPages locale routes', () => {
  159. it('publishes every route in both locales and selects paired sources', () => {
  160. const byRoute = new Map(docsPages.map(page => [page.route, page]))
  161. for (const page of docsPages.filter(page => page.locale === 'root')) {
  162. const counterpart = byRoute.get(`en/${page.route}`)
  163. expect(counterpart, page.route).toBeDefined()
  164. expect(counterpart?.locale).toBe('en')
  165. if (page.contentLocale === 'zh-CN') {
  166. expect(page.source).toMatch(/\.zh\.md$/)
  167. expect(page.contentLocale).toBe('zh-CN')
  168. expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
  169. expect(counterpart?.contentLocale).toBe('en-US')
  170. } else {
  171. expect(counterpart?.source).toBe(page.source)
  172. expect(counterpart?.contentLocale).toBe(page.contentLocale)
  173. }
  174. }
  175. })
  176. it('projects translated core-data pages while retaining explicit English fallbacks', () => {
  177. const rootPages = docsPages.filter(page => (
  178. page.locale === 'root' && page.route.startsWith('reference/core-data-structures/')
  179. ))
  180. const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
  181. const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
  182. expect(translated).toHaveLength(18)
  183. expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
  184. expect(fallbacks.map(page => page.source).sort()).toEqual([
  185. 'docs/core-data-structures/commands.md',
  186. 'docs/core-data-structures/goal.md',
  187. 'docs/core-data-structures/pty.md',
  188. ])
  189. })
  190. it('publishes the Cordis core API under matching locale structures', () => {
  191. const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
  192. for (const file of files) {
  193. const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
  194. const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
  195. expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`)
  196. expect(root?.section).toBe('Cordis API')
  197. expect(english?.source).toBe(root?.source)
  198. expect(english?.section).toBe('Cordis Core API')
  199. }
  200. })
  201. })
  202. describe('addProjectionFrontmatter', () => {
  203. it('adds frontmatter to an ordinary Markdown page', () => {
  204. expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
  205. '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
  206. )
  207. })
  208. it('extends existing VitePress frontmatter', () => {
  209. expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
  210. '---\neditSource: "docs/index.md"\nlayout: home\n---\n',
  211. )
  212. })
  213. })
  214. describe('projectedPageContent', () => {
  215. const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
  216. locale: 'root',
  217. contentLocale: 'zh-CN',
  218. source: 'docs/index.zh.md',
  219. route: 'index.md',
  220. label: 'Home',
  221. sidebar,
  222. section: 'Home',
  223. order: 0,
  224. })
  225. it('omits the source-only body from locale home pages', () => {
  226. expect(projectedPageContent(
  227. '---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n',
  228. page(null),
  229. )).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n')
  230. })
  231. it('keeps the full body for ordinary pages', () => {
  232. const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
  233. expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
  234. })
  235. it('rejects a locale home source without frontmatter', () => {
  236. expect(() => projectedPageContent('# Harness\n', page(null)))
  237. .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
  238. })
  239. })