@readme/cli 0.0.29 → 0.0.31

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.
@@ -0,0 +1,217 @@
1
+ import { afterEach, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { __test__ } from './import.js'
4
+
5
+ const {
6
+ injectSectionLandingPages,
7
+ parseMetaRefreshTarget,
8
+ parseHtmlCanonical,
9
+ } = __test__
10
+
11
+ const originalFetch = globalThis.fetch
12
+
13
+ afterEach(() => {
14
+ globalThis.fetch = originalFetch
15
+ })
16
+
17
+ function mockLandingFetch(routes) {
18
+ globalThis.fetch = async (url) => {
19
+ const href = String(url)
20
+ const entry = routes[href]
21
+ if (!entry) return { ok: false, status: 404, url: href, text: async () => '' }
22
+ if (entry instanceof Error) throw entry
23
+ return {
24
+ ok: entry.ok !== false,
25
+ status: entry.status ?? 200,
26
+ url: entry.finalUrl || href,
27
+ text: async () => entry.body || '',
28
+ }
29
+ }
30
+ }
31
+
32
+ test('parseMetaRefreshTarget resolves relative, quoted, and entity-encoded targets', () => {
33
+ const base = 'https://example.com/docs/guides/'
34
+ assert.equal(
35
+ parseMetaRefreshTarget('<meta http-equiv="refresh" content="0;url=/docs/guides/intro">', base),
36
+ 'https://example.com/docs/guides/intro',
37
+ )
38
+ assert.equal(
39
+ parseMetaRefreshTarget("<meta http-equiv='refresh' content='0; url=intro'>", base),
40
+ 'https://example.com/docs/guides/intro',
41
+ )
42
+ assert.equal(
43
+ parseMetaRefreshTarget('<meta http-equiv="refresh" content="0;url=/docs/guides?tab=js&amp;x=1">', base),
44
+ 'https://example.com/docs/guides?tab=js&x=1',
45
+ )
46
+ assert.equal(parseMetaRefreshTarget('<html><title>Guides</title></html>', base), null)
47
+ assert.equal(parseMetaRefreshTarget('', base), null)
48
+ assert.equal(parseMetaRefreshTarget('<meta http-equiv="refresh" content="5">', base), null)
49
+ })
50
+
51
+ test('parseHtmlCanonical resolves relative and entity-encoded hrefs', () => {
52
+ const base = 'https://example.com/docs/guides/'
53
+ assert.equal(
54
+ parseHtmlCanonical('<link rel="canonical" href="/docs/guides/intro">', base),
55
+ 'https://example.com/docs/guides/intro',
56
+ )
57
+ assert.equal(
58
+ parseHtmlCanonical("<link rel='canonical' href='intro'>", base),
59
+ 'https://example.com/docs/guides/intro',
60
+ )
61
+ assert.equal(
62
+ parseHtmlCanonical('<link rel="canonical" href="/docs/guides?tab=js&amp;x=1">', base),
63
+ 'https://example.com/docs/guides?tab=js&x=1',
64
+ )
65
+ assert.equal(parseHtmlCanonical('<html></html>', base), null)
66
+ assert.equal(parseHtmlCanonical('', base), null)
67
+ })
68
+
69
+ test('injectSectionLandingPages prepends a real common-prefix landing page', async () => {
70
+ mockLandingFetch({
71
+ 'https://example.com/docs/guides': { body: '<title>Guides</title>' },
72
+ })
73
+ const organized = {
74
+ categories: [
75
+ {
76
+ title: 'Guides',
77
+ pages: [
78
+ { title: 'Intro', url: 'https://example.com/docs/guides/intro' },
79
+ { title: 'Install', url: 'https://example.com/docs/guides/install' },
80
+ ],
81
+ },
82
+ ],
83
+ }
84
+
85
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
86
+
87
+ assert.equal(organized.categories[0].pages[0].url, 'https://example.com/docs/guides')
88
+ assert.equal(organized.categories[0].pages[0].title, 'Guides')
89
+ assert.equal(organized.categories[0].pages.length, 3)
90
+ })
91
+
92
+ test('injectSectionLandingPages skips client-redirect stub landings via meta refresh', async () => {
93
+ mockLandingFetch({
94
+ 'https://example.com/docs/guides': {
95
+ body: '<meta http-equiv="refresh" content="0;url=/docs/guides/intro">',
96
+ },
97
+ })
98
+ const organized = {
99
+ categories: [
100
+ {
101
+ title: 'Guides',
102
+ pages: [
103
+ { title: 'Intro', url: 'https://example.com/docs/guides/intro' },
104
+ { title: 'Install', url: 'https://example.com/docs/guides/install' },
105
+ ],
106
+ },
107
+ ],
108
+ }
109
+
110
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
111
+ assert.deepEqual(
112
+ organized.categories[0].pages.map((p) => p.url),
113
+ ['https://example.com/docs/guides/intro', 'https://example.com/docs/guides/install'],
114
+ )
115
+ })
116
+
117
+ test('injectSectionLandingPages skips landings whose canonical points at a known child', async () => {
118
+ mockLandingFetch({
119
+ 'https://example.com/docs/guides': {
120
+ body: '<link rel="canonical" href="/docs/guides/intro">',
121
+ },
122
+ })
123
+ const organized = {
124
+ categories: [
125
+ {
126
+ title: 'Guides',
127
+ pages: [
128
+ { title: 'Intro', url: 'https://example.com/docs/guides/intro' },
129
+ { title: 'Install', url: 'https://example.com/docs/guides/install' },
130
+ ],
131
+ },
132
+ ],
133
+ }
134
+
135
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
136
+ assert.equal(organized.categories[0].pages.length, 2)
137
+ })
138
+
139
+ test('injectSectionLandingPages skips landings that HTTP-redirect onto a known child', async () => {
140
+ mockLandingFetch({
141
+ 'https://example.com/docs/guides': {
142
+ finalUrl: 'https://example.com/docs/guides/intro',
143
+ body: '<title>Intro</title>',
144
+ },
145
+ })
146
+ const organized = {
147
+ categories: [
148
+ {
149
+ title: 'Guides',
150
+ pages: [
151
+ { title: 'Intro', url: 'https://example.com/docs/guides/intro' },
152
+ { title: 'Install', url: 'https://example.com/docs/guides/install' },
153
+ ],
154
+ },
155
+ ],
156
+ }
157
+
158
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
159
+ assert.equal(organized.categories[0].pages.length, 2)
160
+ })
161
+
162
+ test('injectSectionLandingPages does not probe the import base or categories with a single child', async () => {
163
+ const seen = []
164
+ globalThis.fetch = async (url) => {
165
+ seen.push(String(url))
166
+ return { ok: true, status: 200, url: String(url), text: async () => '<title>Docs</title>' }
167
+ }
168
+ const organized = {
169
+ categories: [
170
+ {
171
+ title: 'Docs',
172
+ pages: [
173
+ { title: 'A', url: 'https://example.com/docs/a' },
174
+ { title: 'B', url: 'https://example.com/docs/b' },
175
+ ],
176
+ },
177
+ {
178
+ title: 'Lonely',
179
+ pages: [{ title: 'Only', url: 'https://example.com/docs/guides/only' }],
180
+ },
181
+ ],
182
+ }
183
+
184
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
185
+ assert.deepEqual(seen, [])
186
+ assert.equal(organized.categories[0].pages.length, 2)
187
+ assert.equal(organized.categories[1].pages.length, 1)
188
+ })
189
+
190
+ test('injectSectionLandingPages ignores 404s and fetch failures', async () => {
191
+ mockLandingFetch({
192
+ 'https://example.com/docs/guides': new Error('network down'),
193
+ 'https://example.com/docs/api': { ok: false, status: 404, body: '' },
194
+ })
195
+ const organized = {
196
+ categories: [
197
+ {
198
+ title: 'Guides',
199
+ pages: [
200
+ { title: 'Intro', url: 'https://example.com/docs/guides/intro' },
201
+ { title: 'Install', url: 'https://example.com/docs/guides/install' },
202
+ ],
203
+ },
204
+ {
205
+ title: 'API',
206
+ pages: [
207
+ { title: 'Auth', url: 'https://example.com/docs/api/auth' },
208
+ { title: 'Errors', url: 'https://example.com/docs/api/errors' },
209
+ ],
210
+ },
211
+ ],
212
+ }
213
+
214
+ await injectSectionLandingPages(organized, 'https://example.com/docs')
215
+ assert.equal(organized.categories[0].pages.length, 2)
216
+ assert.equal(organized.categories[1].pages.length, 2)
217
+ })
@@ -0,0 +1,158 @@
1
+ import { afterEach, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import matter from 'gray-matter'
7
+ import { __test__ } from './import.js'
8
+
9
+ const { stageOrganized } = __test__
10
+
11
+ const originalLog = console.log
12
+ afterEach(() => {
13
+ console.log = originalLog
14
+ })
15
+
16
+ function withStaging(fn) {
17
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'import-staging-'))
18
+ console.log = () => {}
19
+ try {
20
+ return fn(stagingDir)
21
+ } finally {
22
+ fs.rmSync(stagingDir, { recursive: true, force: true })
23
+ }
24
+ }
25
+
26
+ function readFm(filePath) {
27
+ return matter(fs.readFileSync(filePath, 'utf8')).data
28
+ }
29
+
30
+ test('stageOrganized writes parent pages as index.md and leaves as slug.md', () => {
31
+ withStaging((stagingDir) => {
32
+ const parent = {
33
+ title: 'Getting Started',
34
+ url: 'https://example.com/docs/getting-started',
35
+ pages: [{ title: 'Install', url: 'https://example.com/docs/getting-started/install' }],
36
+ }
37
+ const leaf = { title: 'Pricing', url: 'https://example.com/docs/pricing' }
38
+
39
+ const result = stageOrganized(
40
+ { categories: [{ title: 'Guides', pages: [parent, leaf] }] },
41
+ stagingDir,
42
+ )
43
+
44
+ const parentIndex = path.join(stagingDir, 'docs', 'Guides', 'getting-started', 'index.md')
45
+ const child = path.join(stagingDir, 'docs', 'Guides', 'getting-started', 'install.md')
46
+ const leafFile = path.join(stagingDir, 'docs', 'Guides', 'pricing.md')
47
+ const siblingParent = path.join(stagingDir, 'docs', 'Guides', 'getting-started.md')
48
+
49
+ assert.equal(fs.existsSync(parentIndex), true)
50
+ assert.equal(fs.existsSync(child), true)
51
+ assert.equal(fs.existsSync(leafFile), true)
52
+ assert.equal(fs.existsSync(siblingParent), false)
53
+
54
+ assert.equal(readFm(parentIndex).title, 'Getting Started')
55
+ assert.equal(readFm(parentIndex)['x-import'], 'https://example.com/docs/getting-started')
56
+ assert.equal(readFm(child).title, 'Install')
57
+ assert.equal(readFm(leafFile).title, 'Pricing')
58
+
59
+ const guidesOrder = fs.readFileSync(path.join(stagingDir, 'docs', 'Guides', '_order.yaml'), 'utf8')
60
+ assert.match(guidesOrder, /^- getting-started$/m)
61
+ assert.match(guidesOrder, /^- pricing$/m)
62
+ assert.doesNotMatch(guidesOrder, /^- index$/m)
63
+
64
+ const nestedOrder = fs.readFileSync(
65
+ path.join(stagingDir, 'docs', 'Guides', 'getting-started', '_order.yaml'),
66
+ 'utf8',
67
+ )
68
+ assert.match(nestedOrder, /^- install$/m)
69
+ assert.equal(result.fileCount, 3)
70
+ })
71
+ })
72
+
73
+ test('stageOrganized rescues /api/docs pages when skipping API reference categories', () => {
74
+ withStaging((stagingDir) => {
75
+ const result = stageOrganized(
76
+ {
77
+ categories: [
78
+ {
79
+ title: 'API Reference',
80
+ pages: [
81
+ { title: 'List pets', url: 'https://example.com/api/reference/pets' },
82
+ { title: 'Guides', url: 'https://example.com/api/docs/guides/intro' },
83
+ {
84
+ title: 'Mixed',
85
+ pages: [{ title: 'Pricing', url: 'https://example.com/api/docs/pricing' }],
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ },
91
+ stagingDir,
92
+ { skipApiReference: true },
93
+ )
94
+
95
+ assert.equal(result.skippedApiRef, 1)
96
+ assert.equal(fs.existsSync(path.join(stagingDir, 'reference')), false)
97
+
98
+ const rescuedDir = path.join(stagingDir, 'docs', 'API Docs')
99
+ const guides = path.join(rescuedDir, 'intro.md')
100
+ const pricing = path.join(rescuedDir, 'introduction', 'pricing.md')
101
+ assert.equal(fs.existsSync(guides), true)
102
+ assert.equal(fs.existsSync(pricing), true)
103
+ assert.equal(readFm(guides)['x-import'], 'https://example.com/api/docs/guides/intro')
104
+ assert.equal(readFm(pricing)['x-import'], 'https://example.com/api/docs/pricing')
105
+ })
106
+ })
107
+
108
+ test('stageOrganized namespaces nested category folders that would otherwise share a basename', () => {
109
+ withStaging((stagingDir) => {
110
+ stageOrganized(
111
+ {
112
+ categories: [
113
+ { title: 'Fundamentals', pages: [{ title: 'Hello', url: 'https://example.com/docs/hello' }] },
114
+ { title: 'cockroach/Fundamentals', pages: [{ title: 'SQL', url: 'https://example.com/docs/sql' }] },
115
+ ],
116
+ },
117
+ stagingDir,
118
+ )
119
+
120
+ const topLevel = path.join(stagingDir, 'docs', 'Fundamentals', 'hello.md')
121
+ const nestedDir = path.join(stagingDir, 'docs', 'cockroach', 'cockroach-fundamentals')
122
+ const nestedPage = path.join(nestedDir, 'sql.md')
123
+ const nestedIndex = path.join(nestedDir, 'index.md')
124
+
125
+ assert.equal(fs.existsSync(topLevel), true)
126
+ assert.equal(fs.existsSync(nestedPage), true)
127
+ assert.equal(fs.existsSync(nestedIndex), true)
128
+ assert.equal(readFm(nestedIndex).title, 'Fundamentals')
129
+ assert.equal('x-import' in readFm(nestedIndex), false)
130
+
131
+ const docsOrder = fs.readFileSync(path.join(stagingDir, 'docs', '_order.yaml'), 'utf8')
132
+ assert.match(docsOrder, /^- Fundamentals$/m)
133
+ assert.match(docsOrder, /^- cockroach\/cockroach-fundamentals$/m)
134
+ })
135
+ })
136
+
137
+ test('stageOrganized quotes YAML-unsafe slugs in _order.yaml', () => {
138
+ withStaging((stagingDir) => {
139
+ stageOrganized(
140
+ {
141
+ categories: [
142
+ {
143
+ title: 'Guides',
144
+ pages: [
145
+ { title: 'True', url: 'https://example.com/docs/true' },
146
+ { title: 'Null', url: 'https://example.com/docs/null' },
147
+ ],
148
+ },
149
+ ],
150
+ },
151
+ stagingDir,
152
+ )
153
+
154
+ const order = fs.readFileSync(path.join(stagingDir, 'docs', 'Guides', '_order.yaml'), 'utf8')
155
+ assert.match(order, /^- "true"$/m)
156
+ assert.match(order, /^- "null"$/m)
157
+ })
158
+ })
@@ -134,3 +134,249 @@ test('resolveRedirectedSourceUrl returns null when fetch throws', async () => {
134
134
  }
135
135
  assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
136
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
+ })
@@ -1,11 +1,9 @@
1
- import { createRequire } from 'node:module'
2
1
  import { collectFiles, runValidators } from '../utils/lint.js'
3
2
  import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
4
3
  import { printHeader, isAgenticCli } from '../utils/eyes.js'
5
4
  import * as styles from '../utils/styles.js'
6
-
7
- const require = createRequire(import.meta.url)
8
- const pkg = require('../../package.json')
5
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js'
6
+ import pkg from '../../package.json' with { type: 'json' }
9
7
 
10
8
  export const command = 'lint'
11
9
  export const order = 1
@@ -59,6 +57,8 @@ export async function run(options, _cmd, ctx) {
59
57
 
60
58
  reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
61
59
 
60
+ writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
61
+
62
62
  if (hasErrors) {
63
63
  // Wait for stdout to flush (important when piped to a file), then exit
64
64
  await new Promise((resolve) => process.stdout.write('', resolve))