@readme/cli 0.0.27 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +5 -5
  2. package/src/cli.js +3 -3
  3. package/src/commands/import-oas.test.js +277 -0
  4. package/src/commands/import.classify.test.js +172 -0
  5. package/src/commands/import.js +2848 -443
  6. package/src/commands/import.landing.test.js +217 -0
  7. package/src/commands/import.slug-planner.test.js +86 -0
  8. package/src/commands/import.staging.test.js +158 -0
  9. package/src/commands/import.test.js +382 -0
  10. package/src/commands/oas-sync.js +362 -92
  11. package/src/prompts/index.js +101 -13
  12. package/src/utils/lint.js +2 -2
  13. package/src/utils/llms.js +224 -0
  14. package/src/utils/llms.test.js +302 -0
  15. package/src/utils/reporter.js +2 -1
  16. package/src/utils/slug-for-entry.js +16 -0
  17. package/src/utils/url-segs.js +124 -0
  18. package/src/utils/url-segs.test.js +239 -0
  19. package/src/validators/components.js +11 -7
  20. package/src/validators/duplicates.js +8 -8
  21. package/src/validators/frontmatter.js +11 -0
  22. package/src/validators/links.js +2 -8
  23. package/src/validators/oas-reference.js +13 -38
  24. package/src/validators/ordering.js +169 -83
  25. package/vendor/TOOLS.md +1 -1
  26. package/src/dev/.next/app-build-manifest.json +0 -20
  27. package/src/dev/.next/build-manifest.json +0 -31
  28. package/src/dev/.next/cache/.rscinfo +0 -1
  29. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  30. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  40. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  43. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  50. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  69. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  70. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  71. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  72. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  73. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  74. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  75. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  76. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  77. package/src/dev/.next/package.json +0 -1
  78. package/src/dev/.next/prerender-manifest.json +0 -11
  79. package/src/dev/.next/react-loadable-manifest.json +0 -1
  80. package/src/dev/.next/routes-manifest.json +0 -1
  81. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  82. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  83. package/src/dev/.next/server/app/page.js +0 -349
  84. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  85. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  86. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  87. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  88. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  89. package/src/dev/.next/server/middleware-manifest.json +0 -32
  90. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  91. package/src/dev/.next/server/middleware.js +0 -1113
  92. package/src/dev/.next/server/next-font-manifest.js +0 -1
  93. package/src/dev/.next/server/next-font-manifest.json +0 -1
  94. package/src/dev/.next/server/pages-manifest.json +0 -5
  95. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  96. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  97. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  98. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  99. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  100. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  101. package/src/dev/.next/server/webpack-runtime.js +0 -209
  102. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  103. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  104. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  105. package/src/dev/.next/static/chunks/app/page.js +0 -28
  106. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  107. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  108. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  109. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  110. package/src/dev/.next/static/css/app/layout.css +0 -559
  111. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  112. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  113. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  114. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  115. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  116. package/src/dev/.next/trace +0 -21
  117. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  118. package/src/dev/.next/types/app/layout.ts +0 -84
  119. package/src/dev/.next/types/app/page.ts +0 -84
  120. package/src/dev/.next/types/cache-life.d.ts +0 -141
  121. package/src/dev/.next/types/package.json +0 -1
  122. package/src/dev/.next/types/routes.d.ts +0 -55
  123. package/src/validators/numbering.js +0 -134
@@ -0,0 +1,382 @@
1
+ import { afterEach, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { __test__ } from './import.js'
4
+
5
+ const originalFetch = globalThis.fetch
6
+
7
+ afterEach(() => {
8
+ globalThis.fetch = originalFetch
9
+ })
10
+
11
+ function mockLlmsFetch(files) {
12
+ const seen = []
13
+ globalThis.fetch = async (url) => {
14
+ const href = String(url)
15
+ seen.push(href)
16
+ if (!Object.hasOwn(files, href)) return new Response('', { status: 404 })
17
+ return new Response(files[href], {
18
+ status: 200,
19
+ headers: { 'content-type': 'text/plain' },
20
+ })
21
+ }
22
+ return seen
23
+ }
24
+
25
+ function mockRedirectFetch(files) {
26
+ globalThis.fetch = async (url) => {
27
+ const entry = files[String(url)]
28
+ if (!entry) return { ok: false, status: 404, url: String(url), text: async () => '' }
29
+ return { ok: true, status: 200, url: entry.finalUrl || String(url), text: async () => entry.body || '' }
30
+ }
31
+ }
32
+
33
+ function mergedItems(merged) {
34
+ return merged.parsed.sections.flatMap((section) => section.items.map((item) => ({ text: item.text, url: item.url })))
35
+ }
36
+
37
+ test('llms discovery and merge resolve nested files, dedupe canonical pages, and preserve distinct page states', async () => {
38
+ const seen = mockLlmsFetch({
39
+ 'https://example.com/docs/llms.txt': `# Docs\n\n## Index\n- [Guide llms](guide/llms.txt)\n- [API llms](https://example.com/docs/api/llms.txt)\n`,
40
+ 'https://example.com/docs/guide/llms.txt': `# Guide\n\n## Guides\n- [Rendered redirect](/getting-started)\n- [Rendered slash](/getting-started/)\n- [Rendered index](/getting-started/index.html)\n- [Markdown source](/getting-started.md)\n- [Search API](/search?product=api)\n- [Search SDK](/search?product=sdk)\n- [Search API Anchor](/search?product=api#overview)\n- [Hash only](#local)\n- [Mail](mailto:test@example.com)\n- [JS](javascript:alert(1))\n- [Malformed](http://%zz)\n`,
41
+ 'https://example.com/docs/api/llms.txt': `# API\n\n## Reference\n- [Reference](/reference.md)\n- [OpenAPI](/openapi.yaml)\n`,
42
+ })
43
+
44
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com/docs/'))
45
+ const merged = __test__.mergeValidHits(discovery.hits)
46
+ const items = mergedItems(merged)
47
+ const urls = items.map((item) => item.url)
48
+
49
+ assert(seen.includes('https://example.com/docs/guide/llms.txt'))
50
+ assert(seen.includes('https://example.com/docs/api/llms.txt'))
51
+
52
+ assert.equal(urls.filter((url) => url.includes('/docs/guide/getting-started')).length, 1)
53
+ assert(urls.includes('https://example.com/docs/guide/getting-started.md'))
54
+
55
+ assert(urls.includes('https://example.com/docs/guide/search?product=api'))
56
+ assert(urls.includes('https://example.com/docs/guide/search?product=sdk'))
57
+ assert.equal(urls.filter((url) => url.includes('/docs/guide/search?product=api')).length, 1)
58
+
59
+ assert(urls.includes('https://example.com/docs/api/reference.md'))
60
+ assert(urls.includes('https://example.com/openapi.yaml'))
61
+
62
+ assert(!urls.some((url) => url.includes('%zz')))
63
+ assert(!urls.some((url) => url.startsWith('mailto:') || url.startsWith('javascript:')))
64
+ })
65
+
66
+ test('llms.txt fetched through a cross-origin redirect resolves links against the final URL', async () => {
67
+ mockRedirectFetch({
68
+ 'https://docs.example.com/llms.txt': {
69
+ finalUrl: 'https://example.com/docs/llms.txt',
70
+ body: `# Docs\n\n## Guides\n- [Sample APIs](/docs/sample-apis.md)\n- [Policies](/policies.md)\n`,
71
+ },
72
+ })
73
+
74
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://docs.example.com'))
75
+ const merged = __test__.mergeValidHits(discovery.hits)
76
+ const urls = mergedItems(merged).map((item) => item.url)
77
+
78
+ assert(urls.includes('https://example.com/docs/sample-apis.md'))
79
+ assert(urls.includes('https://example.com/docs/policies.md'))
80
+ assert(!urls.some((url) => url.startsWith('https://docs.example.com/')))
81
+ })
82
+
83
+ test('nested llms.txt with its own redirect resolves links on its final path', async () => {
84
+ mockRedirectFetch({
85
+ 'https://example.com/llms.txt': {
86
+ body: `# Root\n\n## Index\n- [Guide llms](/guide/llms.txt)\n`,
87
+ },
88
+ 'https://example.com/guide/llms.txt': {
89
+ finalUrl: 'https://example.com/docs/guide/llms.txt',
90
+ body: `# Guide\n\n## Guides\n- [Intro](intro.md)\n- [Setup](setup.md)\n`,
91
+ },
92
+ })
93
+
94
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com'))
95
+ const merged = __test__.mergeValidHits(discovery.hits)
96
+ const urls = mergedItems(merged).map((item) => item.url)
97
+
98
+ assert(urls.includes('https://example.com/docs/guide/intro.md'))
99
+ assert(urls.includes('https://example.com/docs/guide/setup.md'))
100
+ assert(!urls.includes('https://example.com/guide/intro.md'))
101
+ assert(!urls.includes('https://example.com/guide/setup.md'))
102
+ })
103
+
104
+ test('resolveRedirectedSourceUrl adopts cross-origin path redirects', async () => {
105
+ mockRedirectFetch({
106
+ 'https://docs.example.com/': { finalUrl: 'https://example.com/docs' },
107
+ })
108
+ const final = await __test__.resolveRedirectedSourceUrl(new URL('https://docs.example.com/'))
109
+ assert.equal(final?.href, 'https://example.com/docs')
110
+ })
111
+
112
+ test('resolveRedirectedSourceUrl ignores trailing-slash redirects', async () => {
113
+ mockRedirectFetch({
114
+ 'https://example.com/docs': { finalUrl: 'https://example.com/docs/' },
115
+ })
116
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
117
+ })
118
+
119
+ test('resolveRedirectedSourceUrl ignores query and hash-only redirects', async () => {
120
+ mockRedirectFetch({
121
+ 'https://example.com/docs': { finalUrl: 'https://example.com/docs?lang=en#top' },
122
+ })
123
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
124
+ })
125
+
126
+ test('resolveRedirectedSourceUrl returns null on non-ok responses', async () => {
127
+ globalThis.fetch = async (url) => ({ ok: false, status: 500, url: String(url), text: async () => '' })
128
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
129
+ })
130
+
131
+ test('resolveRedirectedSourceUrl returns null when fetch throws', async () => {
132
+ globalThis.fetch = async () => {
133
+ throw new Error('network down')
134
+ }
135
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
136
+ })
137
+
138
+ function mockDocsProbeFetch(files, { catchAllBody } = {}) {
139
+ globalThis.fetch = async (url) => {
140
+ const href = String(url)
141
+ if (new URL(href).hostname !== 'example.com') throw new Error('getaddrinfo ENOTFOUND')
142
+ const entry = files[href]
143
+ if (entry) return { ok: true, status: 200, url: entry.finalUrl || href, text: async () => entry.body || '' }
144
+ if (catchAllBody && !href.endsWith('/llms.txt')) return { ok: true, status: 200, url: href, text: async () => catchAllBody }
145
+ return { ok: false, status: 404, url: href, text: async () => '' }
146
+ }
147
+ }
148
+
149
+ test('resolveDocsBaseUrl adopts a path route whose trailing slash gets stripped by a redirect', async () => {
150
+ mockDocsProbeFetch({
151
+ 'https://example.com/docs/': { finalUrl: 'https://example.com/docs', body: '<title>API Docs</title>' },
152
+ })
153
+ const result = await __test__.resolveDocsBaseUrl(new URL('https://example.com'))
154
+ assert.equal(result?.url.href, 'https://example.com/docs/')
155
+ assert.equal(result?.kind, 'path')
156
+ assert.equal(result?.hasLlms, false)
157
+ })
158
+
159
+ test('resolveDocsBaseUrl rejects a path route that redirects to the homepage', async () => {
160
+ mockDocsProbeFetch({
161
+ 'https://example.com/docs/': { finalUrl: 'https://example.com/' },
162
+ })
163
+ assert.equal(await __test__.resolveDocsBaseUrl(new URL('https://example.com')), null)
164
+ })
165
+
166
+ test('resolveDocsBaseUrl skips SPA catch-all routes and adopts the route with distinct content', async () => {
167
+ mockDocsProbeFetch(
168
+ {
169
+ 'https://example.com/developers/': { finalUrl: 'https://example.com/developers', body: '<title>API Documentation</title>' },
170
+ },
171
+ { catchAllBody: '<title>Example - Empowering</title>' },
172
+ )
173
+ const result = await __test__.resolveDocsBaseUrl(new URL('https://example.com'))
174
+ assert.equal(result?.url.href, 'https://example.com/developers/')
175
+ assert.equal(result?.kind, 'path')
176
+ assert.equal(result?.hasLlms, false)
177
+ })
178
+
179
+ test('resolveDocsBaseUrl returns null when every candidate fails', async () => {
180
+ mockDocsProbeFetch({})
181
+ assert.equal(await __test__.resolveDocsBaseUrl(new URL('https://example.com')), null)
182
+ })
183
+
184
+ test('inCandidateScope normalizes trailing slashes and enforces segment boundaries', () => {
185
+ const candidate = { kind: 'path', url: new URL('https://example.com/docs/') }
186
+ assert.equal(__test__.inCandidateScope(candidate, 'https://example.com/docs'), true)
187
+ assert.equal(__test__.inCandidateScope(candidate, 'https://www.example.com/docs'), true)
188
+ assert.equal(__test__.inCandidateScope(candidate, 'https://example.com/docs/llms.txt'), true)
189
+ assert.equal(__test__.inCandidateScope(candidate, 'https://example.com/DOCS//'), true)
190
+ assert.equal(__test__.inCandidateScope(candidate, 'https://example.com/docsomething'), false)
191
+ assert.equal(__test__.inCandidateScope(candidate, 'https://example.com/'), false)
192
+ })
193
+
194
+ function fernHtml(fragments, { aside = true, generator = false, extraBody = '' } = {}) {
195
+ const scripts = fragments.map((fragment) => `<script>self.__next_f.push([1,${JSON.stringify(fragment)}])</script>`).join('\n')
196
+ const head = generator ? '<meta name="generator" content="https://buildwithfern.com">' : ''
197
+ const sidebar = aside ? '<aside id="fern-sidebar" class="fern-sidebar-container"></aside>' : ''
198
+ return `<!doctype html><html><head>${head}</head><body>${sidebar}${extraBody}${scripts}</body></html>`
199
+ }
200
+
201
+ function mockHtmlFetch(pages) {
202
+ globalThis.fetch = async (url) => {
203
+ const entry = pages[String(url)]
204
+ if (entry === undefined) return { ok: false, status: 404, url: String(url), text: async () => '' }
205
+ if (entry instanceof Error) throw entry
206
+ return { ok: true, status: 200, url: String(url), text: async () => entry }
207
+ }
208
+ }
209
+
210
+ const GEN1_TREE =
211
+ '7a:["$","$L7b",null,{"children":[' +
212
+ '{"type":"sidebarGroup","id":"sidebar-group:section:docs/get-started","collapsed":"$undefined","children":[' +
213
+ '{"type":"section","id":"section:docs/get-started","title":"Get started","slug":"docs/get-started","hidden":false,"pointsTo":"$undefined","children":[' +
214
+ '{"type":"page","id":"page:intro","title":"Introduction","slug":"intro","hidden":false},' +
215
+ '{"type":"page","id":"page:secret","title":"Secret","slug":"secret","hidden":true},' +
216
+ '{"type":"section","id":"section:docs/nested","title":"Nested","slug":"nested/overview","hidden":false,"pointsTo":"$undefined","overviewPageId":"nested/overview.mdx","children":[' +
217
+ '{"type":"page","id":"page:nested/deep","title":"Deep page","slug":"nested/deep","hidden":false}]}]}]}]}]'
218
+
219
+ const GEN2_TREE =
220
+ '7a:["$","$L7b",null,{"children":[' +
221
+ '{"type":"sidebarGroup","id":"c00ed6ec6366da0ae76c9bd27e2e2c23bf3785a61bbb0990","collapsed":"$undefined","children":[' +
222
+ '{"type":"section","id":"28eaf69e90e0a08edb3d6840439eeacb0b9dbdb3","title":"Get started","slug":"docs/get-started","hidden":false,"pointsTo":"$undefined","children":[' +
223
+ '{"type":"page","id":"bc59f5966f28f9f0c31ac71f02619db1e70e8c65","title":"Introduction","slug":"intro","hidden":false},' +
224
+ '{"type":"page","id":"3d9a0da0ad64e4f360afee1dee83e4ae1b848dff","title":"Secret","slug":"secret","hidden":true},' +
225
+ '{"type":"section","id":"75861c60edfc32eefed7d2a77f7d1b9563a7bb74","title":"Nested","slug":"nested/overview","hidden":false,"pointsTo":"$undefined","overviewPageId":"nested/overview.mdx","children":[' +
226
+ '{"type":"page","id":"03b7470af4ce00f9a9dc979ba32fec3fd1c4a7bd","title":"Deep page","slug":"nested/deep","hidden":false}]}]}]}]}]'
227
+
228
+ function assertGetStartedTree(nav) {
229
+ assert.equal(nav.categories.length, 1)
230
+ const [category] = nav.categories
231
+ assert.equal(category.title, 'Get started')
232
+ assert.deepEqual(
233
+ category.pages.map((p) => p.url),
234
+ ['https://fern.example/intro', 'https://fern.example/nested/overview'],
235
+ )
236
+ assert.equal(category.pages[0].title, 'Introduction')
237
+ assert(!category.pages.some((p) => p.title === 'Secret'))
238
+ const nested = category.pages[1]
239
+ assert.equal(nested.title, 'Nested')
240
+ assert.deepEqual(nested.pages, [{ title: 'Deep page', url: 'https://fern.example/nested/deep' }])
241
+ }
242
+
243
+ test('tryFernNav parses the page:<slug> id generation into nested categories, skipping hidden pages', async () => {
244
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([GEN1_TREE]) })
245
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
246
+ assertGetStartedTree(nav)
247
+ })
248
+
249
+ test('tryFernNav parses the hashed id generation into the same categories', async () => {
250
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([GEN2_TREE]) })
251
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
252
+ assertGetStartedTree(nav)
253
+ })
254
+
255
+ test('tryFernNav detects via generator meta when the sidebar aside is absent', async () => {
256
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([GEN1_TREE], { aside: false, generator: true }) })
257
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
258
+ assertGetStartedTree(nav)
259
+ })
260
+
261
+ test('tryFernNav returns null when neither detection signal is present', async () => {
262
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([GEN1_TREE], { aside: false }) })
263
+ assert.equal(await __test__.tryFernNav('https://fern.example/intro', []), null)
264
+ })
265
+
266
+ test('tryFernNav ignores prose mentions of the detection signals', async () => {
267
+ mockHtmlFetch({
268
+ 'https://fern.example/intro': fernHtml([GEN1_TREE], {
269
+ aside: false,
270
+ extraBody: '<p>Docs generated with buildwithfern.com use an aside with id="fern-sidebar".</p>',
271
+ }),
272
+ })
273
+ assert.equal(await __test__.tryFernNav('https://fern.example/intro', []), null)
274
+ })
275
+
276
+ test('tryFernNav returns null when the page has no flight chunks', async () => {
277
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([]) })
278
+ assert.equal(await __test__.tryFernNav('https://fern.example/intro', []), null)
279
+ })
280
+
281
+ test('tryFernNav retries from a known page when the entry URL is dead', async () => {
282
+ mockHtmlFetch({ 'https://fern.example/nested/deep': fernHtml([GEN1_TREE]) })
283
+ const nav = await __test__.tryFernNav('https://fern.example/dead-slug', [
284
+ { title: 'Deep page', url: 'https://fern.example/nested/deep.md', description: undefined },
285
+ ])
286
+ assert.equal(nav.categories.length, 1)
287
+ assert.equal(nav.categories[0].title, 'Get started')
288
+ assert.deepEqual(nav.categories[0].pages[1].pages, [{ title: 'Deep page', url: 'https://fern.example/nested/deep.md' }])
289
+ })
290
+
291
+ test('tryFernNav returns null when the entry URL is dead and the known page is not Fern', async () => {
292
+ mockHtmlFetch({ 'https://fern.example/nested/deep': fernHtml([GEN1_TREE], { aside: false }) })
293
+ assert.equal(
294
+ await __test__.tryFernNav('https://fern.example/dead-slug', [{ title: 'Deep page', url: 'https://fern.example/nested/deep.md', description: undefined }]),
295
+ null,
296
+ )
297
+ })
298
+
299
+ test('tryFernNav keeps the sidebar title but takes urls and descriptions from llms.txt', async () => {
300
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([GEN1_TREE]) })
301
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [
302
+ { title: 'Welcome', url: 'https://fern.example/intro.md', description: 'Start here' },
303
+ ])
304
+ assert.deepEqual(nav.categories[0].pages[0], { title: 'Introduction', url: 'https://fern.example/intro.md', description: 'Start here' })
305
+ })
306
+
307
+ test('tryFernNav treats section pointsTo as an alias: the first child keeps its page and the section becomes a titled group', async () => {
308
+ const tree =
309
+ '7a:["$","$L7b",null,{"children":[' +
310
+ '{"type":"sidebarGroup","id":"sidebar-group:section:docs/guides","children":[' +
311
+ '{"type":"section","id":"section:docs/guides","title":"Guides","slug":"docs/guides","hidden":false,"pointsTo":"$undefined","overviewPageId":"$undefined","children":[' +
312
+ '{"type":"section","id":"section:docs/guides/billing","title":"Billing","slug":"docs/guides/billing","hidden":false,"pointsTo":"billing/manage","overviewPageId":"$undefined","children":[' +
313
+ '{"type":"page","id":"page:billing/manage","title":"Manage billing","slug":"billing/manage","hidden":false},' +
314
+ '{"type":"page","id":"page:billing/limits","title":"Billing limits","slug":"billing/limits","hidden":false}]}]}]}]}]'
315
+ mockHtmlFetch({ 'https://fern.example/intro': fernHtml([tree]) })
316
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
317
+ const [category] = nav.categories
318
+ assert.equal(category.title, 'Guides')
319
+ const billing = category.pages[0]
320
+ assert.equal(billing.title, 'Billing')
321
+ assert.equal(billing.url, null)
322
+ assert.deepEqual(
323
+ billing.pages.map((p) => [p.title, p.url]),
324
+ [
325
+ ['Manage billing', 'https://fern.example/billing/manage'],
326
+ ['Billing limits', 'https://fern.example/billing/limits'],
327
+ ],
328
+ )
329
+ })
330
+
331
+ const TAB_LIST =
332
+ '5f:["$","$L60",null,{"tabs":[' +
333
+ '{"type":"tab","id":"tab:docs","title":"Docs","slug":"docs","hidden":false,"pointsTo":"intro","child":{"type":"sidebarRoot","id":"sidebar:|||aa11","collapsed":"$undefined","children":[]}},' +
334
+ '{"type":"tab","id":"tab:api","title":"API Reference","slug":"api","hidden":false,"pointsTo":"api/start","child":{"type":"sidebarRoot","id":"sidebar:|||bb22","collapsed":"$undefined","children":[]}}]}]'
335
+
336
+ const API_TAB_TREE =
337
+ '9c:["$","$L9d",null,{"children":[' +
338
+ '{"type":"apiPackage","id":"api-pkg:api/pets","title":"Pets","slug":"api/pets","hidden":false,"pointsTo":"api/start","children":[' +
339
+ '{"type":"endpoint","id":"api-leaf:api/start","title":"List pets","slug":"api/start","hidden":false,"method":"GET"},' +
340
+ '{"type":"endpoint","id":"api-leaf:api/pets/create","title":"Create pet","slug":"api/pets/create","hidden":false,"method":"POST"}]}]}]'
341
+
342
+ test('tryFernNav fetches non-active tabs once and merges their trees', async () => {
343
+ mockHtmlFetch({
344
+ 'https://fern.example/intro': fernHtml([GEN1_TREE, TAB_LIST]),
345
+ 'https://fern.example/api/start': fernHtml([API_TAB_TREE]),
346
+ })
347
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
348
+ assert.deepEqual(
349
+ nav.categories.map((c) => c.title),
350
+ ['Get started', 'Pets'],
351
+ )
352
+ assert.deepEqual(
353
+ nav.categories[1].pages.map((p) => p.url),
354
+ ['https://fern.example/api/start', 'https://fern.example/api/pets/create'],
355
+ )
356
+ })
357
+
358
+ test('tryFernNav names an untitled tab-tree root after its tab', async () => {
359
+ const untitledTabTree =
360
+ '9c:["$","$L9d",null,{"children":[' +
361
+ '{"type":"apiPackage","id":"api-pkg:api","title":"$undefined","slug":"api","hidden":false,"pointsTo":"api/start","children":[' +
362
+ '{"type":"endpoint","id":"api-leaf:api/start","title":"List pets","slug":"api/start","hidden":false,"method":"GET"},' +
363
+ '{"type":"endpoint","id":"api-leaf:api/pets/create","title":"Create pet","slug":"api/pets/create","hidden":false,"method":"POST"}]}]}]'
364
+ mockHtmlFetch({
365
+ 'https://fern.example/intro': fernHtml([GEN1_TREE, TAB_LIST]),
366
+ 'https://fern.example/api/start': fernHtml([untitledTabTree]),
367
+ })
368
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
369
+ assert.deepEqual(
370
+ nav.categories.map((c) => c.title),
371
+ ['Get started', 'API Reference'],
372
+ )
373
+ })
374
+
375
+ test('tryFernNav keeps the entry tree when a tab fetch fails', async () => {
376
+ mockHtmlFetch({
377
+ 'https://fern.example/intro': fernHtml([GEN1_TREE, TAB_LIST]),
378
+ 'https://fern.example/api/start': new Error('network down'),
379
+ })
380
+ const nav = await __test__.tryFernNav('https://fern.example/intro', [])
381
+ assertGetStartedTree(nav)
382
+ })