@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
@@ -9,7 +9,18 @@ import matter from 'gray-matter'
9
9
  import * as styles from '../utils/styles.js'
10
10
  import { syncOas } from './oas-sync.js'
11
11
  import OASNormalize from 'oas-normalize'
12
- import { slotOrphansPrompt, iconizeNavPrompt, organizeFromSectionsPrompt, organizeFromScratchPrompt, stripCodeFences } from '../prompts/index.js'
12
+ import {
13
+ slotOrphansPrompt,
14
+ slotOrphansOutputSchema,
15
+ iconizeNavPrompt,
16
+ iconizeNavOutputSchema,
17
+ organizeFromSectionsPrompt,
18
+ organizeFromSectionsOutputSchema,
19
+ organizeFromScratchPrompt,
20
+ organizeFromScratchOutputSchema,
21
+ } from '../prompts/index.js'
22
+ import { analyzeLlmsTxt } from '../utils/llms.js'
23
+ import { urlTrieSegs, extractUrlPathSegments, normalizePath, stripSegmentExtensions, compareCanonicalUrlPreference, llmsPageDedupeKey } from '../utils/url-segs.js'
13
24
 
14
25
  export const command = 'import'
15
26
  export const order = 7
@@ -17,12 +28,21 @@ export const description = 'Import content from a URL and package it as a ReadMe
17
28
  export const hidden = true
18
29
  export const skipBootstrap = true
19
30
 
31
+ export const DEFAULT_MODEL = 'claude-sonnet-5'
32
+
20
33
  export function args(cmd) {
21
- cmd.requiredOption('--source <url-or-file>', 'URL to import from, or path to a local OpenAPI spec (.json/.yaml/.yml)')
34
+ cmd.requiredOption(
35
+ '--source <url-or-file...>',
36
+ 'One or more URLs to import from (space-separated or repeated), or a single local OpenAPI spec (.json/.yaml/.yml)',
37
+ )
22
38
  cmd.option('-o, --output <path>', 'Output zip path (defaults to <basename>-readme.zip in cwd)')
23
- cmd.addOption(new Option('-m, --model <name>', 'Claude model alias: haiku, sonnet, opus').choices(['haiku', 'sonnet', 'opus']).default('sonnet'))
39
+ cmd.addOption(new Option('-m, --model <name>', 'Claude model alias: haiku, sonnet, opus').choices(['haiku', 'sonnet', 'opus']).default(DEFAULT_MODEL))
24
40
  cmd.option('--firecrawl-key <key>', 'Firecrawl API key (or set FIRECRAWL_API_KEY env var) — enables JS-rendered sidebar scraping')
25
41
  cmd.option('--skip-api-reference', 'Drop pages routed to the API Reference / reference dir. Use when uploading the OAS spec separately.')
42
+ cmd.option(
43
+ '--separate-changelog',
44
+ 'Move changelog / release-notes pages into a top-level changelog/ directory instead of docs/Changelog/. Note: changelog/ is outside the git-format schema, so it will not lint or sync to ReadMe.',
45
+ )
26
46
  // Internal dev-only flag: skip the zip, keep staging, and boot the dev server
27
47
  // against it for quick visual previews. Hidden from --help.
28
48
  cmd.addOption(new Option('--test').hideHelp())
@@ -32,22 +52,59 @@ export function args(cmd) {
32
52
  cmd.addOption(new Option('--debug').hideHelp())
33
53
  }
34
54
 
55
+ // Docs locations probed when the user hands us a bare homepage. Each is tried
56
+ // as a subdomain swap (docs.example.com) and, unless subdomain-only, a path
57
+ // prefix (example.com/docs/). Order is preference order — earlier wins ties.
58
+ const WELL_KNOWN_DOC_ROUTES = ['docs', 'doc', 'developers', 'developer', 'documentation', 'documentations', 'platform']
59
+ // Routes probed only as a subdomain (platform.example.com). Their path form
60
+ // (example.com/platform/) is usually a marketing/product page, not docs.
61
+ const SUBDOMAIN_ONLY_DOC_ROUTES = new Set(['platform'])
62
+ // Max concurrent HTTP probes when checking whether a section's landing URL
63
+ // (e.g. /docs/cli) really exists before importing it as a real overview page.
64
+ const LANDING_PROBE_CAP = 8
65
+ // Well-known spec locations probed on every import. Tried under the
66
+ // post-adoption origin, the pre-adoption origin when the docs-route probe
67
+ // switched hosts, and (first three only) under a path-kind docs base.
68
+ const WELL_KNOWN_OAS_PATHS = [
69
+ 'openapi.json',
70
+ 'openapi.yaml',
71
+ 'openapi.yml',
72
+ 'swagger.json',
73
+ 'swagger.yaml',
74
+ '.well-known/openapi.json',
75
+ '.well-known/openapi.yaml',
76
+ 'api/openapi.json',
77
+ 'docs/openapi.json',
78
+ 'v3/api-docs',
79
+ 'api-docs',
80
+ 'swagger/v1/swagger.json',
81
+ ]
82
+ // Per-source cap on candidate specs. Candidates are sorted by discovery-source
83
+ // confidence first, so the cap trims the least trusted ones.
84
+ const MAX_OAS_CANDIDATES_PER_SOURCE = 10
85
+ const OAS_SOURCE_CONFIDENCE = ['llms', 'mintlify', 'probe', 'html', 'sitemap', 'link-sweep']
86
+
35
87
  /**
36
88
  * Run the importer programmatically. Mirrors the CLI command but throws on
37
89
  * fatal errors instead of calling `process.exit`, and returns a result object
38
90
  * on success.
39
91
  *
40
92
  * @param {object} options
41
- * @param {string} options.source URL to import from, or path to a local OAS spec.
93
+ * @param {string | string[]} options.source One or more URLs to import from, or a single path to a local OAS spec.
94
+ * Multiple URLs are merged into one zip; same-titled categories merge their pages.
42
95
  * @param {string} [options.output] Output zip path. Defaults to `<basename>-readme.zip` in cwd.
43
96
  * @param {string} [options.model] Claude model alias: 'haiku' | 'sonnet' | 'opus'. Defaults to 'sonnet'.
44
97
  * @param {string} [options.firecrawlKey] Firecrawl API key (falls back to FIRECRAWL_API_KEY env var).
45
98
  * @param {boolean} [options.skipApiReference] Drop pages routed to the API Reference dir.
99
+ * @param {boolean} [options.separateChangelog] Move changelog pages into a top-level `changelog/` dir instead of `docs/Changelog/`.
46
100
  * @param {boolean} [options.test] Skip the zip, keep staging, and boot the dev server.
47
101
  * @param {boolean} [options.debug] Dump intermediate pipeline artifacts to a tmp dir.
48
102
  * @returns {Promise<{ source: 'url' | 'oas', outputZip?: string, stagingDir?: string, fileCount: number, duration: number, phases: Array<{ label: string, ms: number }> }>}
49
103
  */
50
104
  export async function importDocs(options) {
105
+ const model = options.model || DEFAULT_MODEL
106
+ options = { ...options, model }
107
+
51
108
  const startedAt = Date.now()
52
109
  const phases = []
53
110
  const timePhase = async (label, fn) => {
@@ -59,40 +116,285 @@ export async function importDocs(options) {
59
116
 
60
117
  const debugSnapshots = options.debug ? {} : null
61
118
 
62
- // Dispatch: http(s) URL → docs-site scrape flow; anything else → local OAS.
63
- if (!/^https?:\/\//i.test(options.source)) {
64
- return runOasImport(options.source, options, startedAt, phases, timePhase)
65
- }
119
+ // Normalize source → array; preserve back-compat with single-string callers.
120
+ const rawSources = Array.isArray(options.source) ? options.source : [options.source]
121
+ if (rawSources.length === 0) throw new Error('At least one --source is required')
66
122
 
67
- let sourceUrl
68
- try {
69
- sourceUrl = new URL(options.source)
70
- } catch {
71
- throw new Error(`Invalid --source URL: ${options.source}`)
123
+ // Dispatch: any non-URL entry triggers the local-OAS path, which is
124
+ // strictly single-source.
125
+ const hasNonUrl = rawSources.some((s) => !/^https?:\/\//i.test(s))
126
+ if (hasNonUrl) {
127
+ if (rawSources.length > 1) {
128
+ throw new Error('OAS imports accept a single --source; URL imports can accept multiple.')
129
+ }
130
+ return runOasImport(rawSources[0], options, startedAt, phases, timePhase)
72
131
  }
73
132
 
74
- const outputZip = path.resolve(options.output || path.join(process.cwd(), `${sourceUrl.hostname}-readme.zip`))
133
+ const sourceUrls = rawSources.map((s) => {
134
+ try {
135
+ return new URL(s)
136
+ } catch {
137
+ throw new Error(`Invalid --source URL: ${s}`)
138
+ }
139
+ })
140
+
141
+ const hostnameJoined = sourceUrls.map((u) => u.hostname).join('-')
142
+ const outputZip = path.resolve(options.output || path.join(process.cwd(), `${hostnameJoined}-readme.zip`))
75
143
 
76
144
  console.log()
77
- styles.info(`Importing from ${styles.bold(sourceUrl.toString())}`)
145
+ if (sourceUrls.length === 1) {
146
+ styles.info(`Importing from ${styles.bold(sourceUrls[0].toString())}`)
147
+ } else {
148
+ styles.info(`Importing from ${styles.bold(String(sourceUrls.length))} sources:`)
149
+ for (const u of sourceUrls) styles.info(styles.dim(` · ${u.toString()}`))
150
+ }
78
151
  if (!options.test) styles.info(`Output: ${styles.bold(outputZip)}`)
79
152
  console.log()
80
153
 
81
- // Build the list of llms.txt URLs to probe, walking up the supplied path
82
- // from most-specific to root. For `https://mintlify.com/docs/quickstart`
83
- // we try `/docs/quickstart/llms.txt`, then `/docs/llms.txt`, then root.
84
- // This catches sites that scope llms.txt to a docs subpath.
85
- const llmsCandidates = buildLlmsCandidates(sourceUrl)
86
- styles.info(`Checking for llms.txt (${llmsCandidates.length} candidate${llmsCandidates.length === 1 ? '' : 's'})...`)
154
+ // Per-source pipeline: discovery + scrape + organize, in parallel.
155
+ // Each call returns an `organized` object with the standard shape
156
+ // `{ title, categories: [...] }`; we merge them before staging.
157
+ const perSourceOrganized = await Promise.all(
158
+ sourceUrls.map((sourceUrl) => produceOrganizedForSource(sourceUrl, options, timePhase, debugSnapshots)),
159
+ )
87
160
 
88
- const { llms, llmsUrl } = await timePhase('fetch llms.txt', async () => {
89
- for (const candidate of llmsCandidates) {
90
- const res = await fetchLlmsTxt(candidate)
91
- if (res.ok) return { llms: res, llmsUrl: candidate }
92
- styles.info(styles.dim(` ${candidate} → ${res.status ? `HTTP ${res.status}` : res.error || 'failed'}`))
161
+ const organized = sourceUrls.length === 1 ? perSourceOrganized[0] : mergeOrganized(perSourceOrganized)
162
+
163
+ // Union the candidate OpenAPI-spec URLs across all sources, deduped by URL
164
+ // (mergeOrganized intentionally drops non-category fields, so collect from
165
+ // the per-source results directly).
166
+ const oasJsonUrls = []
167
+ const seenOasUrls = new Set()
168
+ for (const o of perSourceOrganized) {
169
+ for (const item of o.oasJsonUrls || []) {
170
+ if (seenOasUrls.has(item.url)) continue
171
+ seenOasUrls.add(item.url)
172
+ oasJsonUrls.push(item)
93
173
  }
94
- return { llms: null, llmsUrl: null }
95
- })
174
+ }
175
+
176
+ const llmsPaths = new Set()
177
+ for (const o of perSourceOrganized) {
178
+ for (const p of o.llmsPaths || []) llmsPaths.add(p)
179
+ }
180
+
181
+ if (debugSnapshots) {
182
+ debugSnapshots['05-organized.json'] = organized
183
+ const debugDir = path.join(os.tmpdir(), `readme-import-debug-${hostnameJoined}-${Date.now()}`)
184
+ fs.mkdirSync(debugDir, { recursive: true })
185
+ // Probe-hit candidates carry prefetched spec bytes — summarize instead of
186
+ // dumping megabytes of serialized Buffer into the snapshot.
187
+ const bufferSummary = (key, value) =>
188
+ value && value.type === 'Buffer' && Array.isArray(value.data) ? `<Buffer ${value.data.length} bytes>` : value
189
+ for (const [name, data] of Object.entries(debugSnapshots)) {
190
+ fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, bufferSummary, 2))
191
+ }
192
+ styles.info(`${styles.dim(`Debug snapshots → ${debugDir}`)}`)
193
+ }
194
+ console.log()
195
+
196
+ console.log(` ${styles.bold(organized.title || '(untitled)')}`)
197
+ for (const cat of organized.categories || []) {
198
+ console.log()
199
+ const iconLabel = cat.icon ? `${styles.brand(`[${cat.icon}]`)} ` : ''
200
+ console.log(` ${iconLabel}${styles.bold(cat.title)}`)
201
+ printPagesTree(cat.pages || [], 2)
202
+ }
203
+
204
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-import-'))
205
+
206
+ let result
207
+ try {
208
+ styles.info(`Staging frontmatter stubs in ${styles.bold(stagingDir)}...`)
209
+ const stageStart = Date.now()
210
+ const staged = await timePhase('stage stubs', async () =>
211
+ stageOrganized(organized, stagingDir, { skipApiReference: !!options.skipApiReference, llmsPaths }),
212
+ )
213
+
214
+ // Changelog pages stage into docs/Changelog/ (a schema-valid docs
215
+ // category). With --separate-changelog, relocate them afterwards into a
216
+ // top-level changelog/ dir — done as a post-staging move so the staged
217
+ // tree is a valid git-format layout up to this point.
218
+ if (options.separateChangelog) {
219
+ const movedChangelog = relocateChangelogDir(stagingDir)
220
+ if (movedChangelog > 0) {
221
+ styles.info(
222
+ `Moved ${styles.bold(String(movedChangelog))} changelog page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('changelog/')} (--separate-changelog).`,
223
+ )
224
+ }
225
+ }
226
+
227
+ const landingTitle =
228
+ organized.title ||
229
+ (sourceUrls.length === 1 ? sourceUrls[0].hostname : sourceUrls.map((u) => u.hostname).join(' + '))
230
+ ensureDocsLandingPage(stagingDir, landingTitle)
231
+ styles.ok(
232
+ `Staged ${styles.bold(String(staged.fileCount))} stub${staged.fileCount === 1 ? '' : 's'} across ${styles.bold(String(staged.dirCount))} director${staged.dirCount === 1 ? 'y' : 'ies'} in ${styles.bold(formatDuration(Date.now() - stageStart))}.`,
233
+ )
234
+ if (staged.skippedApiRef > 0) {
235
+ styles.info(`Skipped ${styles.bold(String(staged.skippedApiRef))} API reference page${staged.skippedApiRef === 1 ? '' : 's'} (--skip-api-reference)`)
236
+ }
237
+
238
+ // Download captured candidate specs into <stagingDir>/oas/. Raw bytes only —
239
+ // validation + OpenAPI detection + upload all happen in the runner. Staged
240
+ // here (not in docs/ or reference/) so it rides the zip but is invisible to
241
+ // the docs build. Non-fatal: a failed download is logged and skipped.
242
+ if (oasJsonUrls.length > 0) {
243
+ const oasCount = await timePhase('download oas specs', () => downloadOasSpecs(oasJsonUrls, stagingDir))
244
+ if (oasCount > 0) {
245
+ styles.ok(`Downloaded ${styles.bold(String(oasCount))} candidate OpenAPI spec${oasCount === 1 ? '' : 's'} into ${styles.bold('oas/')}.`)
246
+ }
247
+ }
248
+ console.log()
249
+
250
+ if (options.test) {
251
+ styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Staged ${styles.bold(String(staged.fileCount))} files at ${styles.bold(stagingDir)}`)
252
+ console.log()
253
+ styles.info('Starting the dev server for preview...')
254
+ console.log()
255
+ await runDevPreview(stagingDir)
256
+ return { source: 'url', stagingDir, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
257
+ }
258
+
259
+ if (staged.fileCount === 0) {
260
+ styles.warning('Staging directory is empty — skipping zip.')
261
+ return { source: 'url', fileCount: 0, duration: Date.now() - startedAt, phases }
262
+ }
263
+
264
+ styles.info(`Packaging ${styles.bold(String(staged.fileCount))} files into ${styles.bold(outputZip)}...`)
265
+ await timePhase('zip', () => createZip(stagingDir, outputZip))
266
+
267
+ console.log()
268
+ styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Your ReadMe import is ready at ${styles.bold(outputZip)}`)
269
+ console.log(styles.dim(` ⏱ ${phases.map((p) => `${p.label} ${formatDuration(p.ms)}`).join(' · ')}`))
270
+ result = { source: 'url', outputZip, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
271
+ } finally {
272
+ if (!options.test) {
273
+ fs.rmSync(stagingDir, { recursive: true, force: true })
274
+ }
275
+ }
276
+
277
+ return result
278
+ }
279
+
280
+ /**
281
+ * Run the discovery + scrape + organize pipeline for ONE source URL and
282
+ * return its `organized` object (`{ title, categories: [...] }`). Pure
283
+ * per-source work — no staging, no zipping. `importDocs` runs this in
284
+ * parallel across multiple sources and merges the results.
285
+ *
286
+ * `debugSnapshots`, when provided, gets per-source keys suffixed with
287
+ * the hostname so parallel runs don't clobber each other.
288
+ */
289
+ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSnapshots) {
290
+ const redirected = await timePhase('follow source redirects', () => resolveRedirectedSourceUrl(sourceUrl))
291
+ if (redirected) {
292
+ styles.info(`${styles.bold(sourceUrl.toString())} redirects to ${styles.bold(redirected.toString())} — rebasing import onto the final URL.`)
293
+ sourceUrl = redirected
294
+ }
295
+
296
+ // Probe well-known docs routes (docs./developer. subdomains and /docs/,
297
+ // … path prefixes) and adopt the first that resolves as the real base, so everything downstream runs
298
+ const preAdoptionOrigin = sourceUrl.origin
299
+ const docsBase = await timePhase('probe well-known docs routes', () => resolveDocsBaseUrl(sourceUrl))
300
+ if (docsBase) {
301
+ styles.info(
302
+ `${styles.bold(sourceUrl.toString())} looks like a homepage — switching to docs route ${styles.bold(docsBase.url.toString())}${styles.dim(docsBase.hasLlms ? ' (llms.txt found)' : ' (no llms.txt; using for scrape fallback)')}.`,
303
+ )
304
+ sourceUrl = docsBase.url
305
+ }
306
+
307
+ // Fired here so the spec-path probe fully overlaps llms/scrape/organize;
308
+ // awaited just before the candidates are attached to `organized`.
309
+ const oasProbePromise = probeWellKnownOasPaths(sourceUrl, docsBase, preAdoptionOrigin).catch(() => [])
310
+
311
+ // Discover llms.txt files by BFS walking parents AND children. Seed the
312
+ // frontier with the walk-up paths (source → root). Any "hit" (file exists
313
+ // with at least one link row) expands the frontier: parent path + distinct
314
+ // first-child segments extracted from its URL list. Invalid hits (fail the
315
+ // usability ratio) still expand — they're stepping stones — only valid
316
+ // hits contribute content to the merged result. See
317
+ // mydocs.com/section1/section2 → also tries /section1/, /, and every
318
+ // sibling section2 we discover along the way.
319
+ styles.info(`Discovering llms.txt (walking up and down from ${styles.bold(sourceUrl.toString())})...`)
320
+
321
+ const discovery = await timePhase('discover llms.txt', () => discoverLlmsTxt(sourceUrl))
322
+ for (const hit of discovery.hits) {
323
+ if (hit.usable) {
324
+ styles.info(styles.dim(` ${hit.llmsUrl} → valid (${hit.stats.linkItems} links)`))
325
+ } else {
326
+ styles.info(styles.dim(` ${hit.llmsUrl} → invalid: ${hit.reason} (kept as stepping stone)`))
327
+ }
328
+ }
329
+ for (const miss of discovery.misses) {
330
+ styles.info(styles.dim(` ${miss.url} → ${miss.status ? `HTTP ${miss.status}` : miss.error || 'no llms.txt'}`))
331
+ }
332
+ let llms = mergeValidHits(discovery.hits)
333
+ let llmsUrl = llms?.llmsUrl || null
334
+ const skippedLlms = discovery.skipped
335
+
336
+ // Capture spec-shaped links (.json/.yaml/.yml — candidate OpenAPI specs)
337
+ // BEFORE anything else sees the parsed tree. They're removed in place here,
338
+ // downloaded into the staged `oas/` dir by importDocs, and validated +
339
+ // uploaded by the runner. Stub generation never sees them.
340
+ const oasCandidates = []
341
+ const seenOasCandidateUrls = new Set()
342
+ const addOasCandidates = (items) => {
343
+ for (const item of items || []) {
344
+ if (!item || !item.url) continue
345
+ const url = item.url.split('#')[0]
346
+ if (seenOasCandidateUrls.has(url)) continue
347
+ seenOasCandidateUrls.add(url)
348
+ oasCandidates.push({ ...item, url })
349
+ }
350
+ }
351
+ if (llms) {
352
+ const llmsSpecUrls = extractOasSpecUrlsFromParsed(llms.parsed)
353
+ if (llmsSpecUrls.length > 0) {
354
+ styles.info(styles.dim(`Captured ${llmsSpecUrls.length} spec link${llmsSpecUrls.length === 1 ? '' : 's'} as candidate OpenAPI spec${llmsSpecUrls.length === 1 ? '' : 's'} → oas/.`))
355
+ addOasCandidates(llmsSpecUrls)
356
+ }
357
+ }
358
+
359
+ // Drop the remaining asset/meta items (llms-full.txt, .xml, …) from
360
+ // the merged parsed result up-front, so every downstream consumer — knownUrls
361
+ // AND the "use llms.txt sections directly" organize path — sees a clean URL
362
+ // list. Spec-shaped items were already pulled out above.
363
+ if (llms) {
364
+ const dropped = dropAssetItemsFromParsed(llms.parsed)
365
+ if (dropped > 0) {
366
+ styles.info(styles.dim(`Dropped ${dropped} asset/meta URL${dropped === 1 ? '' : 's'} (.yaml/.xml/.toml, llms*.txt) from llms.txt items.`))
367
+ }
368
+ }
369
+
370
+ if (llms) {
371
+ const narrowed = narrowToDocsSubtreeIfNeeded(llms, sourceUrl, discovery.hits)
372
+ if (narrowed) {
373
+ styles.info(
374
+ styles.dim(
375
+ `Narrowed merged result to /${narrowed.segment}/ subtree (kept ${narrowed.kept}, dropped ${narrowed.dropped} out-of-scope page${narrowed.dropped === 1 ? '' : 's'}).`,
376
+ ),
377
+ )
378
+ }
379
+ }
380
+
381
+ // Fall back to scrape if the merged llms.txt covers too few endpoints to
382
+ // be useful as a structural backbone. Replaces the old per-file
383
+ // MIN_LINK_ROWS=10 check (now relaxed in utils/llms.js) with a global
384
+ // post-merge floor — many tiny per-product llms.txt files can still pass
385
+ // as long as their union clears the bar.
386
+ const MIN_MERGED_LINK_COUNT = 10
387
+ if (llms) {
388
+ const mergedLinkCount = llms.parsed.sections.reduce((n, s) => n + s.items.length, 0)
389
+ if (mergedLinkCount < MIN_MERGED_LINK_COUNT) {
390
+ styles.warning(
391
+ `Merged llms.txt has only ${styles.bold(String(mergedLinkCount))} endpoint${mergedLinkCount === 1 ? '' : 's'} (< ${MIN_MERGED_LINK_COUNT}) — falling back to scrape.`,
392
+ )
393
+ skippedLlms.push({ url: llmsUrl, reason: `merged total only ${mergedLinkCount} link${mergedLinkCount === 1 ? '' : 's'}` })
394
+ llms = null
395
+ llmsUrl = null
396
+ }
397
+ }
96
398
  console.log()
97
399
 
98
400
  let sitemapUrl = null
@@ -105,7 +407,7 @@ export async function importDocs(options) {
105
407
  const sitemapCandidates = buildSitemapCandidates(sourceUrl)
106
408
  const scopePrefix = deriveSitemapScope(sourceUrl)
107
409
  styles.info(
108
- `No llms.txt — checking for sitemap.xml (${sitemapCandidates.length} candidate${sitemapCandidates.length === 1 ? '' : 's'})${scopePrefix ? `, scoped to ${styles.bold(scopePrefix)}` : ''}...`,
410
+ `${skippedLlms.length > 0 ? 'No usable llms.txt' : 'No llms.txt'} — checking for sitemap.xml (${sitemapCandidates.length} candidate${sitemapCandidates.length === 1 ? '' : 's'})${scopePrefix ? `, scoped to ${styles.bold(scopePrefix)}` : ''}...`,
109
411
  )
110
412
  const sitemapResult = await timePhase('fetch sitemap.xml', async () => {
111
413
  for (const candidate of sitemapCandidates) {
@@ -119,25 +421,85 @@ export async function importDocs(options) {
119
421
  console.log()
120
422
  if (sitemapResult) {
121
423
  sitemapUrl = sitemapResult.url
122
- sitemapKnownUrls = sitemapUrlsToKnownUrls(sitemapResult.urls)
424
+ const sitemapOasSink = []
425
+ sitemapKnownUrls = sitemapUrlsToKnownUrls(sitemapResult.urls, sitemapOasSink)
426
+ addOasCandidates(sitemapOasSink)
123
427
  styles.ok(`Found sitemap.xml at ${styles.bold(sitemapUrl)} — ${styles.bold(String(sitemapKnownUrls.length))} URL${sitemapKnownUrls.length === 1 ? '' : 's'} in scope.`)
124
428
  } else {
125
429
  styles.warning(`No llms.txt or sitemap.xml found — falling back to sidebar discovery via scrape.`)
126
430
  }
127
431
  } else {
128
- styles.info(styles.dim(`Using ${llmsUrl}.`))
432
+ const s = llms.stats
433
+ if (llms.sourceFiles.length === 1) {
434
+ styles.info(styles.dim(`Using ${llmsUrl} (${s.conforming}/${s.total} lines conforming, ratio ${s.ratio.toFixed(2)}).`))
435
+ } else {
436
+ styles.info(styles.dim(`Merged ${llms.sourceFiles.length} llms.txt files (root: ${llmsUrl}; aggregate ratio ${s.ratio.toFixed(2)}).`))
437
+ }
438
+ if (llms.parsed.h3Fallback) {
439
+ styles.info(styles.dim(`H2 sections oversized — re-parsed using H3 (###) headings as section boundaries.`))
440
+ }
441
+ }
442
+
443
+ // Pre-extract changelog pages so AI / URL clustering / section-direct paths
444
+ // never see them as candidates for organization. We re-attach them as a
445
+ // dedicated Changelog category once `organized` is finalized below — that
446
+ // guarantees they route to docs/Changelog/ (or to changelog/ with
447
+ // --separate-changelog) regardless of which organize path ran. Done here,
448
+ // before knownUrls is derived, so every downstream consumer operates on the
449
+ // already-changelog-free view.
450
+ let extractedChangelog = []
451
+ if (llms) {
452
+ extractedChangelog = extractChangelogFromSections(llms.parsed.sections)
453
+ } else if (sitemapKnownUrls.length > 0) {
454
+ const { extracted, kept } = partitionChangelogFromKnownUrls(sitemapKnownUrls)
455
+ extractedChangelog = extracted
456
+ sitemapKnownUrls = kept
457
+ }
458
+ if (extractedChangelog.length > 0) {
459
+ styles.info(
460
+ styles.dim(
461
+ `Pre-extracted ${extractedChangelog.length} changelog page${extractedChangelog.length === 1 ? '' : 's'} — bypassing organization, attaching as Changelog category at the end.`,
462
+ ),
463
+ )
464
+ }
465
+
466
+ // Dedupe items across llms.parsed.sections by URL (first occurrence wins).
467
+ // Some llms.txt files cross-reference the same page under multiple headings —
468
+ // feeding duplicate URLs into the organize step causes the same page to appear
469
+ // in multiple sidebar sections regardless of which path (direct, icons, full
470
+ // reorg) runs downstream.
471
+ if (llms) {
472
+ const seenItemUrls = new Set()
473
+ let deduped = 0
474
+ for (const section of llms.parsed.sections) {
475
+ const before = section.items.length
476
+ section.items = section.items.filter((item) => {
477
+ const key = normalizePath(item.url)
478
+ if (seenItemUrls.has(key)) return false
479
+ seenItemUrls.add(key)
480
+ return true
481
+ })
482
+ deduped += before - section.items.length
483
+ }
484
+ if (deduped > 0) {
485
+ styles.info(styles.dim(`Deduped ${deduped} cross-section duplicate URL${deduped === 1 ? '' : 's'} from llms.txt sections.`))
486
+ }
129
487
  }
130
488
 
489
+ const dbgSuffix = `-${sourceUrl.hostname}`
131
490
  if (debugSnapshots) {
132
- debugSnapshots['01-llms-parsed.json'] = { llmsUrl, parsed: llms ? llms.parsed : null }
133
- debugSnapshots['01b-sitemap.json'] = { sitemapUrl, urls: sitemapKnownUrls }
491
+ debugSnapshots[`01-llms-parsed${dbgSuffix}.json`] = { llmsUrl, parsed: llms ? llms.parsed : null, skipped: skippedLlms }
492
+ debugSnapshots[`01b-sitemap${dbgSuffix}.json`] = { sitemapUrl, urls: sitemapKnownUrls }
493
+ debugSnapshots[`01c-extracted-changelog${dbgSuffix}.json`] = extractedChangelog
134
494
  }
135
495
 
136
496
  let knownUrls = []
137
497
  if (llms) {
138
498
  const totalItems = llms.parsed.sections.reduce((n, s) => n + s.items.length, 0)
499
+ const srcCount = llms.sourceFiles.length
500
+ const srcLabel = srcCount === 1 ? 'llms.txt' : `${srcCount} llms.txt files (merged)`
139
501
  styles.ok(
140
- `Found llms.txt — ${styles.bold(String(totalItems))} page${totalItems === 1 ? '' : 's'} across ${styles.bold(String(llms.parsed.sections.length))} section${llms.parsed.sections.length === 1 ? '' : 's'}${llms.parsed.title ? ` (${llms.parsed.title})` : ''}.`,
502
+ `Found ${srcLabel} — ${styles.bold(String(totalItems))} page${totalItems === 1 ? '' : 's'} across ${styles.bold(String(llms.parsed.sections.length))} section${llms.parsed.sections.length === 1 ? '' : 's'}${llms.parsed.title ? ` (${llms.parsed.title})` : ''}.`,
141
503
  )
142
504
 
143
505
  const rawKnownUrls = llms.parsed.sections.flatMap((s) => s.items.map((i) => ({ title: i.text, url: i.url, description: i.description })))
@@ -145,13 +507,13 @@ export async function importDocs(options) {
145
507
  // Dedupe llms.txt entries by pathname. Some sites (zod.dev, fumadocs) list
146
508
  // every in-page anchor as its own llms.txt row (`/v4?id=wrapping-up`,
147
509
  // `/v4?id=metadata`, …) even though they all live on one rendered page.
148
- // We prefer the "cleanest" URL per path — the shortest one, which is
149
- // usually the one without a query string or hash.
510
+ // Prefer direct markdown source forms over shorter rendered URLs because
511
+ // they are better import sources for downstream content fetching.
150
512
  const byKnownPath = new Map()
151
513
  for (const p of rawKnownUrls) {
152
514
  const key = normalizePath(p.url)
153
515
  const prev = byKnownPath.get(key)
154
- if (!prev || p.url.length < prev.url.length) byKnownPath.set(key, p)
516
+ if (!prev || compareCanonicalUrlPreference(p.url, prev.url) < 0) byKnownPath.set(key, p)
155
517
  }
156
518
  knownUrls = Array.from(byKnownPath.values())
157
519
  const dropped = rawKnownUrls.length - knownUrls.length
@@ -170,58 +532,117 @@ export async function importDocs(options) {
170
532
  // HTML parsing, so try it before falling back to generic nav scraping.
171
533
  styles.info(`Probing for Mintlify config (docs.json, mint.json)...`)
172
534
  const mintlifyStart = Date.now()
173
- const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey))
535
+ const mintlifyOasSink = []
536
+ const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey, mintlifyOasSink))
537
+ addOasCandidates(mintlifyOasSink)
174
538
  if (debugSnapshots) {
175
- debugSnapshots['02a-mintlify-nav.json'] = mintlifyNav ? JSON.parse(JSON.stringify(mintlifyNav)) : null
539
+ debugSnapshots[`02a-mintlify-nav${dbgSuffix}.json`] = mintlifyNav ? JSON.parse(JSON.stringify(mintlifyNav)) : null
176
540
  }
177
541
  if (mintlifyNav) {
178
- const pageCount = mintlifyNav.categories.reduce((n, c) => n + c.pages.length, 0)
542
+ const pageCount = mintlifyNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
179
543
  styles.ok(
180
544
  `Found Mintlify config at ${styles.bold(mintlifyNav.source)} in ${styles.bold(formatDuration(Date.now() - mintlifyStart))} — ${styles.bold(String(mintlifyNav.categories.length))} categor${mintlifyNav.categories.length === 1 ? 'y' : 'ies'}, ${styles.bold(String(pageCount))} pages.`,
181
545
  )
182
546
  }
183
547
  console.log()
184
548
 
549
+ // Fern sites ship the canonical sidebar in RSC flight chunks on every
550
+ // page — deterministic structure where the Firecrawl scrape usually fails
551
+ // acceptance and llms.txt clustering invents categories.
552
+ let fernNav = null
553
+ if (!mintlifyNav) {
554
+ styles.info(`Probing for Fern nav tree...`)
555
+ const fernStart = Date.now()
556
+ fernNav = await timePhase('fern probe', () => tryFernNav(sourceUrl.toString(), knownUrls))
557
+ if (fernNav) {
558
+ const pageCount = fernNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
559
+ styles.ok(
560
+ `Found Fern nav tree in ${styles.bold(formatDuration(Date.now() - fernStart))} — ${styles.bold(String(fernNav.categories.length))} categor${fernNav.categories.length === 1 ? 'y' : 'ies'}, ${styles.bold(String(pageCount))} page${pageCount === 1 ? '' : 's'}.`,
561
+ )
562
+ }
563
+ console.log()
564
+ }
565
+ if (debugSnapshots) {
566
+ debugSnapshots[`02a2-fern-nav${dbgSuffix}.json`] = fernNav ? JSON.parse(JSON.stringify(fernNav)) : null
567
+ }
568
+
569
+ // Archbee sites embed the canonical document tree in Next.js page data.
570
+ // Their llms.txt export can be a flat, shuffled list, so prefer the tree
571
+ // when present.
572
+ let archbeeNav = null
573
+ if (!mintlifyNav && !fernNav) {
574
+ styles.info(`Probing for Archbee document tree...`)
575
+ const archbeeStart = Date.now()
576
+ const archbeeOasSink = []
577
+ archbeeNav = await timePhase('archbee probe', () => tryArchbeeNav(sourceUrl.toString(), knownUrls, firecrawlKey, archbeeOasSink))
578
+ addOasCandidates(archbeeOasSink)
579
+ if (archbeeNav) {
580
+ const pageCount = archbeeNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
581
+ styles.ok(
582
+ `Found Archbee document tree in ${styles.bold(formatDuration(Date.now() - archbeeStart))} — ${styles.bold(String(pageCount))} page${pageCount === 1 ? '' : 's'}.`,
583
+ )
584
+ }
585
+ console.log()
586
+ }
587
+
185
588
  let scraped
186
589
  let scrapeStart = Date.now()
590
+ const scrapeDiagnostics = {}
187
591
  if (mintlifyNav) {
188
592
  scraped = { title: mintlifyNav.title, categories: mintlifyNav.categories }
593
+ } else if (fernNav) {
594
+ scraped = { title: fernNav.title, categories: fernNav.categories }
595
+ } else if (archbeeNav) {
596
+ scraped = { title: archbeeNav.title, categories: archbeeNav.categories }
189
597
  } else {
190
598
  styles.info(`Scraping sidebar nav from ${styles.bold(sourceUrl.toString())}${firecrawlKey ? ' ' + styles.dim('(via Firecrawl)') : ''}...`)
191
599
  scrapeStart = Date.now()
192
- scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey))
600
+ const scrapeOasSink = []
601
+ scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey, scrapeDiagnostics, scrapeOasSink))
602
+ addOasCandidates(scrapeOasSink)
603
+ if (!scraped) scrapeDiagnostics.reason ??= 'unknown'
193
604
  }
194
605
  if (debugSnapshots) {
195
- debugSnapshots['02-scraped-raw.json'] = scraped ? JSON.parse(JSON.stringify(scraped)) : null
606
+ debugSnapshots[`02-scraped-raw${dbgSuffix}.json`] = scraped ? JSON.parse(JSON.stringify(scraped)) : null
196
607
  }
197
- // Prefer llms.txt when it has strong multi-section structure and the
198
- // scrape was a thin snapshot (common on big multi-tab docs — Stripe, AWS,
199
- // Twilio — where each page only renders its own tab's sidebar). Without
200
- // this override the 4-category scrape wins over a 25-section llms.txt and
201
- // hundreds of real pages end up smeared into orphan buckets.
608
+ // If the sidebar scrape covers less than 75% of the llms.txt URLs, the
609
+ // scrape is too thin to trust as the import's spine. Common on multi-tab
610
+ // docs (Stripe, AWS, Twilio, Xata) where each page only renders its own
611
+ // tab's sidebar — the visible categories would otherwise absorb hundreds
612
+ // of orphan URLs via prefix-matching and produce a misleading tree (e.g.
613
+ // every /docs/* URL dumped under a single "Overview > Xata Documentation"
614
+ // node because that's the one /docs page the scrape saw). Discard the
615
+ // scrape and fall through to the llms.txt path, which uses URL-based
616
+ // clustering when multiple files were merged.
617
+ // API-reference pages are excluded from the denominator: sidebars routinely
618
+ // omit generated endpoint stubs, and those pages are swept into reference/
619
+ // regardless of nav quality, so they say nothing about the nav's fitness as
620
+ // the import's spine.
621
+ let scrapeDiscardedForCoverage = false
202
622
  if (scraped && llms && knownUrls.length > 0) {
203
- const scrapedPages = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
204
- const coverage = scrapedPages / knownUrls.length
205
- const llmsUsable = usableSections(llms.parsed.sections)
206
- if (llmsUsable.length >= 5 && coverage < 0.5) {
623
+ const scrapedPages = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
624
+ const nonReferenceKnown = knownUrls.filter((p) => !urlIsApiReference(p.url))
625
+ const coverage = nonReferenceKnown.length > 0 ? scrapedPages / nonReferenceKnown.length : 1
626
+ if (coverage < 0.75) {
207
627
  styles.info(
208
- `Scrape covered ${styles.bold(Math.round(coverage * 100) + '%')} of llms.txt pages; preferring llms.txt's ${styles.bold(String(llmsUsable.length))} sections for structure.`,
628
+ `Scrape covered ${styles.bold(Math.round(coverage * 100) + '%')} of llms.txt pages (need ≥75%${nonReferenceKnown.length < knownUrls.length ? `, ${knownUrls.length - nonReferenceKnown.length} api-reference pages excluded` : ''}) — discarding scrape and organizing from llms.txt.`,
209
629
  )
210
630
  scraped = null
631
+ scrapeDiscardedForCoverage = true
211
632
  }
212
633
  }
213
634
 
214
635
  if (scraped) {
215
- const directMatches = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
636
+ const directMatches = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
216
637
  if (knownUrls.length > 0) {
217
638
  const slotted = slotOrphansByPath(scraped, knownUrls)
218
639
  if (debugSnapshots) {
219
- debugSnapshots['03-after-slot-by-path.json'] = {
640
+ debugSnapshots[`03-after-slot-by-path${dbgSuffix}.json`] = {
220
641
  scraped: JSON.parse(JSON.stringify(scraped)),
221
642
  unslottedOrphans: slotted,
222
643
  }
223
644
  }
224
- const totalMatched = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
645
+ const totalMatched = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
225
646
  styles.ok(
226
647
  `Scraped nav in ${styles.bold(formatDuration(Date.now() - scrapeStart))} — ${styles.bold(String(scraped.categories.length))} categor${scraped.categories.length === 1 ? 'y' : 'ies'}, ${styles.bold(String(directMatches))} direct matches + ${styles.bold(String(totalMatched - directMatches))} slotted by path = ${styles.bold(String(totalMatched))}/${knownUrls.length}.`,
227
648
  )
@@ -235,40 +656,85 @@ export async function importDocs(options) {
235
656
  styles.info(`Moved ${styles.bold(String(moved))} page${moved === 1 ? '' : 's'} into ${styles.bold('API Reference')} based on URL path.`)
236
657
  }
237
658
 
659
+ // Same idea for changelog / release-notes pages — pull any whose URL
660
+ // carries a changelog-style segment into one "Changelog" category. It
661
+ // routes to docs/Changelog/ unless --separate-changelog relocates it.
662
+ const movedChangelog = reclassifyChangelogPages(scraped)
663
+ if (movedChangelog > 0) {
664
+ styles.info(`Moved ${styles.bold(String(movedChangelog))} page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('Changelog')} based on URL path.`)
665
+ }
666
+
238
667
  if (slotted.length > 0) {
239
- // Mintlify-style docs put API endpoints in a separate tab rooted at
240
- // /api-reference/* (or /api/*, /reference/*). Collapse remaining such
241
- // pages into a single "API Reference" category (absorbing the flat
242
- // category the sweep pass just built, if any) and nest it by resource
243
- // segment so routeCategory() maps the whole thing to ReadMe's
244
- // `reference/` top-level dir.
245
- const apiResult = collectApiReferencePages(slotted, scraped)
246
- const otherOrphans = apiResult.nonApiOrphans
247
- if (apiResult.category) scraped.categories.push(apiResult.category)
248
-
249
- const buckets = bucketOrphansByPathType(otherOrphans, scraped)
250
- for (const b of buckets) scraped.categories.push(b)
251
- if (debugSnapshots) {
252
- debugSnapshots['04-after-orphan-buckets.json'] = {
253
- apiReferenceCollected: apiResult.category
254
- ? {
668
+ // When orphans dwarf direct matches, the sidebar scrape was too thin
669
+ // to trust as the import's spine — keeping it would produce a small
670
+ // "real" tree plus a soup of bucketed-by-URL-type orphan categories.
671
+ // Discard the scrape and cluster every page (scrape + orphans) by
672
+ // its top URL segment instead, so each `/docs/`, `/sdk/`, `/rest-api/`
673
+ // becomes its own category. `nestByUrlHierarchy` (later) handles the
674
+ // empty-parent nesting within each category.
675
+
676
+ // Trip when orphans are at least 2× the direct matches: the scrape
677
+ // accounts for less than a third of the known pages, so its category
678
+ // labels aren't a trustworthy spine for the remainder.
679
+ const orphansDwarfDirect = slotted.length >= directMatches * 2
680
+ const scrapeAllPages = scraped.categories.flatMap((c) => collectUrlPagesDeep(c.pages))
681
+ let reclustered = null
682
+ if (orphansDwarfDirect) {
683
+ reclustered = clusterByUrlPath([...scrapeAllPages, ...slotted])
684
+ }
685
+ styles.info(
686
+ styles.dim(
687
+ ` orphan triage: ${slotted.length} orphan${slotted.length === 1 ? '' : 's'} vs ${directMatches} direct match${directMatches === 1 ? '' : 'es'} (ratio ${directMatches === 0 ? '∞' : (slotted.length / directMatches).toFixed(2)}) — gate ${orphansDwarfDirect ? 'tripped' : 'NOT tripped'} (need ≥2.00); URL re-cluster ${reclustered ? `→ ${reclustered.length} categor${reclustered.length === 1 ? 'y' : 'ies'}` : 'skipped'}`,
688
+ ),
689
+ )
690
+ if (reclustered) {
691
+ scraped.categories = reclustered.map((c) => ({ title: c.title, pages: c.pages }))
692
+ styles.info(
693
+ `Scrape covered only ${styles.bold(String(directMatches))}/${styles.bold(String(knownUrls.length))} pages — discarded as too thin and re-clustered ${styles.bold(String(scrapeAllPages.length + slotted.length))} pages by URL path into ${styles.bold(String(reclustered.length))} categor${reclustered.length === 1 ? 'y' : 'ies'}: ${reclustered.map((c) => styles.bold(c.title)).join(', ')}.`,
694
+ )
695
+ if (debugSnapshots) {
696
+ debugSnapshots[`04-after-orphan-buckets${dbgSuffix}.json`] = {
697
+ mode: 'reclustered-by-url-path',
698
+ scraped: JSON.parse(JSON.stringify(scraped)),
699
+ }
700
+ }
701
+ } else {
702
+ // Mintlify-style docs put API endpoints in a separate tab rooted at
703
+ // /api-reference/* (or /api/*, /reference/*). Collapse remaining such
704
+ // pages into a single "API Reference" category (absorbing the flat
705
+ // category the sweep pass just built, if any) and nest it by resource
706
+ // segment so routeCategory() maps the whole thing to ReadMe's
707
+ // `reference/` top-level dir.
708
+ const linkSweepOasSink = []
709
+ const apiResult = collectApiReferencePages(slotted, scraped, linkSweepOasSink)
710
+ const otherOrphans = apiResult.nonApiOrphans
711
+ if (apiResult.category) scraped.categories.push(apiResult.category)
712
+
713
+ const buckets = bucketOrphansByPathType(otherOrphans, scraped, linkSweepOasSink)
714
+ for (const b of buckets) scraped.categories.push(b)
715
+ addOasCandidates(linkSweepOasSink)
716
+ if (debugSnapshots) {
717
+ debugSnapshots[`04-after-orphan-buckets${dbgSuffix}.json`] = {
718
+ apiReferenceCollected: apiResult.category
719
+ ? {
255
720
  pageCount: apiResult.category.pages.length,
256
721
  mergedFromScraped: apiResult.mergedScrapedTitles,
257
722
  }
258
- : null,
259
- buckets,
260
- scraped: JSON.parse(JSON.stringify(scraped)),
723
+ : null,
724
+ buckets,
725
+ scraped: JSON.parse(JSON.stringify(scraped)),
726
+ }
727
+ }
728
+ const parts = []
729
+ if (apiResult.category) {
730
+ parts.push(`${styles.bold(String(apiResult.category.pages.length))} in ${styles.bold('API Reference')}`)
731
+ }
732
+ for (const b of buckets) {
733
+ parts.push(`${styles.bold(String(b.pages.length))} in ${styles.bold(b.title)}`)
734
+ }
735
+ if (parts.length > 0) {
736
+ styles.info(`${styles.bold(String(slotted.length))} orphan page${slotted.length === 1 ? '' : 's'} bucketed by URL type: ${parts.join(', ')}.`)
261
737
  }
262
- }
263
- const parts = []
264
- if (apiResult.category) {
265
- parts.push(`${styles.bold(String(apiResult.category.pages.length))} in ${styles.bold('API Reference')}`)
266
- }
267
- for (const b of buckets) {
268
- parts.push(`${styles.bold(String(b.pages.length))} in ${styles.bold(b.title)}`)
269
- }
270
- if (parts.length > 0) {
271
- styles.info(`${styles.bold(String(slotted.length))} orphan page${slotted.length === 1 ? '' : 's'} bucketed by URL type: ${parts.join(', ')}.`)
272
738
  }
273
739
  }
274
740
  } else {
@@ -298,9 +764,31 @@ export async function importDocs(options) {
298
764
  } else if (!llms && knownUrls.length === 0) {
299
765
  throw new Error(`No llms.txt or sitemap.xml and the sidebar scrape found no usable structure — can't import ${sourceUrl.toString()}.`)
300
766
  } else if (llms) {
301
- styles.warning(`Couldn't extract a useful nav — falling back to llms.txt-based organization.`)
767
+ if (!scrapeDiscardedForCoverage) {
768
+ const d = scrapeDiagnostics || {}
769
+ if (d.reason === 'no-categories-round0') {
770
+ styles.warning(`Sidebar scrape found no nav structure on the round-0 fetch — falling back to llms.txt organization.`)
771
+ } else if (d.reason === 'below-threshold') {
772
+ styles.warning(
773
+ `Sidebar scrape below acceptance threshold (got ${d.categories} categor${d.categories === 1 ? 'y' : 'ies'}, ${d.matched} matched pages — need ${d.need}) — falling back to llms.txt organization.`,
774
+ )
775
+ } else {
776
+ styles.warning(`Sidebar scrape returned no usable nav — falling back to llms.txt organization.`)
777
+ }
778
+ }
302
779
  } else {
303
- styles.warning(`Couldn't extract a useful nav — falling back to sitemap URL clustering.`)
780
+ if (!scrapeDiscardedForCoverage) {
781
+ const d = scrapeDiagnostics || {}
782
+ if (d.reason === 'no-categories-round0') {
783
+ styles.warning(`Sidebar scrape found no nav structure on the round-0 fetch — falling back to sitemap URL clustering.`)
784
+ } else if (d.reason === 'below-threshold') {
785
+ styles.warning(
786
+ `Sidebar scrape below acceptance threshold (got ${d.categories} categor${d.categories === 1 ? 'y' : 'ies'}, ${d.matched} matched pages — need ${d.need}) — falling back to sitemap URL clustering.`,
787
+ )
788
+ } else {
789
+ styles.warning(`Sidebar scrape returned no usable nav — falling back to sitemap URL clustering.`)
790
+ }
791
+ }
304
792
  }
305
793
  console.log()
306
794
 
@@ -314,9 +802,60 @@ export async function importDocs(options) {
314
802
  categories: scraped.categories.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
315
803
  }
316
804
  } else if (llms) {
317
- const fastPath = sectionsLookUsable(llms.parsed.sections)
318
- styles.info(`Organizing with Claude (${styles.bold(options.model)}, ${fastPath ? 'fast path: icons only' : 'full reorg'})...`)
319
- organized = await timePhase('claude organize', () => organizeWithClaude(llms.parsed, options.model))
805
+ if (llms.sourceFiles.length > 1) {
806
+ // Multi-file merge: H2 section headings across the merged files are
807
+ // heterogeneous and don't form a coherent spine (e.g. elevenlabs.io's
808
+ // root file uses `Docs`/`Products` while /docs/llms.txt uses
809
+ // `API Docs` — concatenating them produces meaningless top-level
810
+ // categories like `docs/Docs`, `docs/Products`, `docs/API Docs`).
811
+ // Throw away the section grouping and re-cluster by URL path instead.
812
+ // Per-page metadata (title, url, description) is still carried over.
813
+ const flatPages = llms.parsed.sections.flatMap((s) =>
814
+ s.items.map((it) => ({
815
+ title: it.text,
816
+ url: it.url,
817
+ ...(it.description ? { description: it.description } : {}),
818
+ })),
819
+ )
820
+ const clustered = clusterByUrlPath(flatPages) || [{ title: 'Documentation', pages: flatPages }]
821
+ styles.info(
822
+ `Multi-file llms.txt merge — clustered ${styles.bold(String(flatPages.length))} pages by URL path into ${styles.bold(String(clustered.length))} categor${clustered.length === 1 ? 'y' : 'ies'} (H2 sections discarded).`,
823
+ )
824
+ organized = {
825
+ title: llms.parsed.title || null,
826
+ categories: clustered.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
827
+ }
828
+ } else {
829
+ // Single-file mode: H2 structure IS the author's intended organization,
830
+ // so trust it. Use sections directly when usable; otherwise run Claude.
831
+ const usable = usableSections(llms.parsed.sections)
832
+ const avgPages = usable.length > 0 ? usable.reduce((n, s) => n + s.items.length, 0) / usable.length : 0
833
+ if (sectionsLookUsable(llms.parsed.sections) && avgPages < 50) {
834
+ styles.info(
835
+ `Using llms.txt sections directly — ${styles.bold(String(usable.length))} sections, ${styles.bold(avgPages.toFixed(1))} avg pages/section (≤50). Skipping Claude.`,
836
+ )
837
+ organized = {
838
+ title: llms.parsed.title || null,
839
+ categories: usable.map((s) => ({
840
+ title: s.title,
841
+ icon: null,
842
+ pages: s.items.map((it) => ({
843
+ title: it.text,
844
+ url: it.url,
845
+ ...(it.description ? { description: it.description } : {}),
846
+ })),
847
+ })),
848
+ }
849
+ } else {
850
+ const fastPath = sectionsLookUsable(llms.parsed.sections)
851
+ styles.info(`Organizing with Claude (${styles.bold(options.model)}, ${fastPath ? 'fast path: icons only' : 'full reorg'})...`)
852
+ organized = await timePhase('claude organize', () => organizeWithClaude(llms.parsed, options.model))
853
+ const movedRef = reclassifyReferencePages(organized)
854
+ if (movedRef > 0) {
855
+ styles.info(`Moved ${styles.bold(String(movedRef))} page${movedRef === 1 ? '' : 's'} into ${styles.bold('API Reference')} based on URL path.`)
856
+ }
857
+ }
858
+ }
320
859
  } else {
321
860
  // Sitemap-only fallback: synthesize categories by clustering the URL
322
861
  // paths. clusterByUrlPath returns null when the URLs don't split cleanly,
@@ -327,75 +866,86 @@ export async function importDocs(options) {
327
866
  categories: clustered.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
328
867
  }
329
868
  }
330
-
869
+
870
+ // Re-attach the pre-extracted changelog pages as their own Changelog
871
+ // category. Done before nestByUrlHierarchy so the injected pages get nested
872
+ // alongside the rest of the tree for consistency. Merges into a pre-existing
873
+ // Changelog category (scrape path's reclassifier may have already built one).
874
+ injectChangelogCategory(organized, extractedChangelog)
875
+
876
+ // Import section landing pages (e.g. /docs/cli) that llms.txt models as a
877
+ // bare heading + child links and so never lists as a page of their own.
878
+ // Runs before nesting so the injected page lands at its trie node and
879
+ // nestByUrlHierarchy renders it as the real parent of its children.
880
+ await injectSectionLandingPages(organized, sourceUrl)
881
+
882
+ // A Fern tree is the authored sidebar: re-nesting by URL invents parent
883
+ // folders the source site renders flat (a section landing absorbing its
884
+ // path-descendant siblings, e.g. vapi's /server-url under Webhooks). Only
885
+ // the sweep-synthesized categories still nest — their pages were moved in
886
+ // flat and have no authored structure.
887
+ const sweepCategories = new Set(['API Reference', 'Changelog'])
331
888
  for (const cat of organized.categories || []) {
889
+ if (fernNav && !sweepCategories.has(cat.title)) continue
332
890
  cat.pages = nestByUrlHierarchy(cat.pages)
333
891
  }
334
892
 
335
893
  styles.ok(`Organized in ${styles.bold(formatDuration(Date.now() - organizeStart))}.`)
336
- if (debugSnapshots) {
337
- debugSnapshots['05-organized.json'] = organized
338
- const debugDir = path.join(os.tmpdir(), `readme-import-debug-${sourceUrl.hostname}-${Date.now()}`)
339
- fs.mkdirSync(debugDir, { recursive: true })
340
- for (const [name, data] of Object.entries(debugSnapshots)) {
341
- fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, null, 2))
342
- }
343
- styles.info(`${styles.dim(`Debug snapshots → ${debugDir}`)}`)
344
- }
345
- console.log()
346
-
347
- console.log(` ${styles.bold(organized.title || '(untitled)')}`)
348
- for (const cat of organized.categories || []) {
349
- console.log()
350
- const iconLabel = cat.icon ? `${styles.brand(`[${cat.icon}]`)} ` : ''
351
- console.log(` ${iconLabel}${styles.bold(cat.title)}`)
352
- printPagesTree(cat.pages || [], 2)
353
- }
354
894
 
355
- const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-import-'))
356
-
357
- let result
358
- try {
359
- styles.info(`Staging frontmatter stubs in ${styles.bold(stagingDir)}...`)
360
- const stageStart = Date.now()
361
- const staged = await timePhase('stage stubs', async () => stageOrganized(organized, stagingDir, { skipApiReference: !!options.skipApiReference }))
362
- ensureDocsLandingPage(stagingDir, organized.title || sourceUrl.hostname)
363
- styles.ok(
364
- `Staged ${styles.bold(String(staged.fileCount))} stub${staged.fileCount === 1 ? '' : 's'} across ${styles.bold(String(staged.dirCount))} director${staged.dirCount === 1 ? 'y' : 'ies'} in ${styles.bold(formatDuration(Date.now() - stageStart))}.`,
895
+ addOasCandidates(await oasProbePromise)
896
+ oasCandidates.sort((a, b) => OAS_SOURCE_CONFIDENCE.indexOf(a.source) - OAS_SOURCE_CONFIDENCE.indexOf(b.source))
897
+ const cappedOasCandidates = oasCandidates.slice(0, MAX_OAS_CANDIDATES_PER_SOURCE)
898
+ if (oasCandidates.length > 0) {
899
+ const bySource = new Map()
900
+ for (const c of cappedOasCandidates) bySource.set(c.source, (bySource.get(c.source) || 0) + 1)
901
+ const summary = Array.from(bySource, ([source, n]) => `${n} ${source}`).join(', ')
902
+ const overCap = oasCandidates.length - cappedOasCandidates.length
903
+ styles.info(
904
+ styles.dim(`OAS candidates: ${summary}${overCap > 0 ? `; dropped ${overCap} over the cap of ${MAX_OAS_CANDIDATES_PER_SOURCE}` : ''}.`),
365
905
  )
366
- if (staged.skippedApiRef > 0) {
367
- styles.info(`Skipped ${styles.bold(String(staged.skippedApiRef))} API reference page${staged.skippedApiRef === 1 ? '' : 's'} (--skip-api-reference)`)
368
- }
369
- console.log()
370
-
371
- if (options.test) {
372
- styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Staged ${styles.bold(String(staged.fileCount))} files at ${styles.bold(stagingDir)}`)
373
- console.log()
374
- styles.info('Starting the dev server for preview...')
375
- console.log()
376
- await runDevPreview(stagingDir)
377
- return { source: 'url', stagingDir, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
378
- }
906
+ }
907
+ organized.oasJsonUrls = cappedOasCandidates
379
908
 
380
- if (staged.fileCount === 0) {
381
- styles.warning('Staging directory is empty — skipping zip.')
382
- return { source: 'url', fileCount: 0, duration: Date.now() - startedAt, phases }
909
+ // Record which page URLs came from llms.txt
910
+ const llmsPaths = new Set()
911
+ if (llms) {
912
+ for (const p of [...knownUrls, ...extractedChangelog]) {
913
+ if (p.url) llmsPaths.add(normalizePath(p.url))
383
914
  }
915
+ }
916
+ organized.llmsPaths = llmsPaths
917
+ return organized
918
+ }
384
919
 
385
- styles.info(`Packaging ${styles.bold(String(staged.fileCount))} files into ${styles.bold(outputZip)}...`)
386
- await timePhase('zip', () => createZip(stagingDir, outputZip))
387
-
388
- console.log()
389
- styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Your ReadMe import is ready at ${styles.bold(outputZip)}`)
390
- console.log(styles.dim(` ⏱ ${phases.map((p) => `${p.label} ${formatDuration(p.ms)}`).join(' · ')}`))
391
- result = { source: 'url', outputZip, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
392
- } finally {
393
- if (!options.test) {
394
- fs.rmSync(stagingDir, { recursive: true, force: true })
920
+ /**
921
+ * Merge per-source `organized` results into one. Same-titled categories MERGE
922
+ * their page lists (intentional — when a user bundles related sources into
923
+ * one project, they probably want a coherent sidebar; `ensureUniqueSlugs` later
924
+ * dedupes any slug collisions globally). First non-null title wins; first
925
+ * non-null icon per merged category wins. Page order within a merged category
926
+ * is the concatenation of input sources in argument order.
927
+ */
928
+ function mergeOrganized(perSource) {
929
+ const title = perSource.map((o) => o.title).find(Boolean) || null
930
+
931
+ const byTitle = new Map()
932
+ const order = []
933
+ for (const o of perSource) {
934
+ for (const cat of o.categories || []) {
935
+ const key = cat.title
936
+ const existing = byTitle.get(key)
937
+ if (existing) {
938
+ existing.pages.push(...(cat.pages || []))
939
+ if (!existing.icon && cat.icon) existing.icon = cat.icon
940
+ } else {
941
+ const copy = { ...cat, pages: [...(cat.pages || [])] }
942
+ byTitle.set(key, copy)
943
+ order.push(copy)
944
+ }
395
945
  }
396
946
  }
397
947
 
398
- return result
948
+ return { title, categories: order }
399
949
  }
400
950
 
401
951
  /**
@@ -798,7 +1348,7 @@ function createZip(sourceDir, outputZip) {
798
1348
  * Returns { source, title, categories } or null if no Mintlify config is
799
1349
  * found or parseable.
800
1350
  */
801
- async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
1351
+ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey, oasSink) {
802
1352
  const origin = new URL(sourceUrl).origin
803
1353
  const fetchHtml = firecrawlKey ? makeFirecrawlFetcher(firecrawlKey) : fetchHtmlDirect
804
1354
 
@@ -810,7 +1360,9 @@ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
810
1360
  const body = await fetchHtml(configUrl)
811
1361
  if (!body) continue
812
1362
  const config = extractMintlifyConfig(body)
813
- if (!config || !config.navigation) continue
1363
+ if (!config) continue
1364
+ if (oasSink) oasSink.push(...extractMintlifyOasCandidates(config, origin))
1365
+ if (!config.navigation) continue
814
1366
  const parsed = parseMintlifyConfig(config, origin, byPath)
815
1367
  if (parsed.categories.length > 0) {
816
1368
  return { source: configUrl, title: parsed.title, categories: parsed.categories }
@@ -829,7 +1381,7 @@ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
829
1381
  function extractMintlifyConfig(body) {
830
1382
  try {
831
1383
  return JSON.parse(body)
832
- } catch {}
1384
+ } catch { }
833
1385
  // Pull out the first balanced `{ ... }` that contains a "navigation" key.
834
1386
  const navIdx = body.indexOf('"navigation"')
835
1387
  if (navIdx === -1) return null
@@ -840,17 +1392,62 @@ function extractMintlifyConfig(body) {
840
1392
  try {
841
1393
  const parsed = JSON.parse(candidate)
842
1394
  if (parsed && parsed.navigation) return parsed
843
- } catch {}
1395
+ } catch { }
844
1396
  }
845
1397
  start = body.lastIndexOf('{', start - 1)
846
1398
  }
847
1399
  return null
848
1400
  }
849
1401
 
850
- function parseMintlifyConfig(config, origin, byPath) {
851
- const title = config.name || null
852
- const categories = []
853
-
1402
+ /**
1403
+ * Collect candidate spec URLs from a Mintlify config's `openapi` fields —
1404
+ * `string | string[] | { source }` at the top level and on any navigation
1405
+ * node. Relative values resolve against the origin. Pushed before the
1406
+ * navigation guard so a config with `openapi` but unusable nav still yields
1407
+ * specs.
1408
+ */
1409
+ function extractMintlifyOasCandidates(config, origin) {
1410
+ const candidates = []
1411
+ const seen = new Set()
1412
+ const addUrl = (raw) => {
1413
+ if (typeof raw !== 'string' || !raw.trim()) return
1414
+ let abs
1415
+ try {
1416
+ abs = new URL(raw, origin)
1417
+ } catch {
1418
+ return
1419
+ }
1420
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') return
1421
+ const href = abs.toString()
1422
+ if (seen.has(href)) return
1423
+ seen.add(href)
1424
+ candidates.push({ url: href, text: null, source: 'mintlify' })
1425
+ }
1426
+ const addValue = (value) => {
1427
+ if (typeof value === 'string') addUrl(value)
1428
+ else if (Array.isArray(value)) for (const v of value) addValue(v)
1429
+ else if (value && typeof value === 'object') addUrl(value.source)
1430
+ }
1431
+ const NAV_KEYS = ['navigation', 'tabs', 'groups', 'anchors', 'versions', 'languages', 'pages', 'dropdowns']
1432
+ const walk = (node) => {
1433
+ if (Array.isArray(node)) {
1434
+ for (const n of node) walk(n)
1435
+ return
1436
+ }
1437
+ if (!node || typeof node !== 'object') return
1438
+ if (node.openapi !== undefined) addValue(node.openapi)
1439
+ for (const key of NAV_KEYS) {
1440
+ if (node[key] !== undefined) walk(node[key])
1441
+ }
1442
+ }
1443
+ walk(config)
1444
+ return candidates
1445
+ }
1446
+
1447
+ function parseMintlifyConfig(config, origin, byPath) {
1448
+ const title = config.name || null
1449
+ const categories = []
1450
+
854
1451
  const slugToTitle = (slug) => {
855
1452
  const base = String(slug).split('/').pop() || slug
856
1453
  return base
@@ -927,6 +1524,327 @@ function parseMintlifyConfig(config, origin, byPath) {
927
1524
  return { title, categories }
928
1525
  }
929
1526
 
1527
+ /**
1528
+ * Archbee exposes its canonical docs tree in Next.js page data as
1529
+ * `_docSpace.publicDocsTree`. That tree preserves author order and nesting,
1530
+ * while Archbee's llms.txt export may be flat and shuffled.
1531
+ *
1532
+ * Returns { title, categories } or null if the page is not Archbee or the
1533
+ * embedded tree is unavailable.
1534
+ */
1535
+ async function tryArchbeeNav(sourceUrl, knownPages, firecrawlKey, oasSink) {
1536
+ const origin = new URL(sourceUrl).origin
1537
+ const fetchHtml = firecrawlKey ? makeFirecrawlFetcher(firecrawlKey) : fetchHtmlDirect
1538
+ const html = await fetchHtml(toBrowsableUrl(sourceUrl))
1539
+ if (html && oasSink) {
1540
+ oasSink.push(...extractOasUrlsFromHtml(html, sourceUrl).map((url) => ({ url, text: null, source: 'html' })))
1541
+ }
1542
+ if (!html || !/publicDocsTree|Archbee/i.test(html)) return null
1543
+
1544
+ const nextData = extractNextData(html)
1545
+ const docSpace = nextData?.props?.pageProps?._docSpace
1546
+ const tree = docSpace?.publicDocsTree
1547
+ if (!Array.isArray(tree) || tree.length === 0) return null
1548
+
1549
+ const byPath = new Map()
1550
+ for (const p of knownPages) byPath.set(normalizePath(p.url), p)
1551
+
1552
+ const pageFromNode = (node) => {
1553
+ if (!node || typeof node !== 'object') return null
1554
+ if (node.isHidden === true) return null
1555
+
1556
+ const title = String(node.name || node.title || node.urlKey || '').trim()
1557
+ const children = Array.isArray(node.children) ? node.children.map(pageFromNode).filter(Boolean) : []
1558
+ if (!title && children.length === 0) return null
1559
+
1560
+ const url = archbeeNodeUrl(origin, node.urlKey)
1561
+ const known = url ? byPath.get(normalizePath(url)) : null
1562
+ const page = {
1563
+ title: known?.title || title || 'Untitled',
1564
+ url,
1565
+ ...(known?.description ? { description: known.description } : {}),
1566
+ ...(children.length > 0 ? { pages: children } : {}),
1567
+ }
1568
+
1569
+ // Root docs pages have no URL path segments, but should stay in their
1570
+ // authored position when the later URL-trie nesting pass runs.
1571
+ if (url && extractUrlPathSegments(url).length === 0) {
1572
+ page._virtualPathSegs = [kebabCase(page.title || 'home') || 'home']
1573
+ }
1574
+
1575
+ return page
1576
+ }
1577
+
1578
+ const pages = tree.map(pageFromNode).filter(Boolean)
1579
+ if (pages.length === 0) return null
1580
+
1581
+ return {
1582
+ title: docSpace.hostingTitle || docSpace.name || null,
1583
+ categories: [{ title: 'Documentation', pages }],
1584
+ }
1585
+ }
1586
+
1587
+ function extractNextData(html) {
1588
+ const m = String(html).match(/<script\b(?=[^>]*\bid=["']__NEXT_DATA__["'])[^>]*>([\s\S]*?)<\/script>/i)
1589
+ if (!m) return null
1590
+ try {
1591
+ return JSON.parse(m[1])
1592
+ } catch {
1593
+ return null
1594
+ }
1595
+ }
1596
+
1597
+ function archbeeNodeUrl(origin, urlKey) {
1598
+ if (urlKey === undefined || urlKey === null) return null
1599
+ const key = String(urlKey || '').trim()
1600
+ if (!key || key === '/') return `${origin}/`
1601
+ if (/^https?:\/\//i.test(key)) return key
1602
+ return `${origin}/${key.replace(/^\/+/, '')}`
1603
+ }
1604
+
1605
+ // Max page fetches while filling in non-active Fern tab trees (one per tab).
1606
+ const FERN_TAB_FETCH_CAP = 8
1607
+
1608
+ const FERN_SECTION_NODE_TYPES = new Set(['section', 'apiReference', 'apiPackage'])
1609
+ const FERN_SKIP_NODE_TYPES = new Set(['link', 'changelog', 'tab'])
1610
+
1611
+ /**
1612
+ * Fern-powered docs sites embed the complete nav tree in Next.js RSC flight
1613
+ * chunks (`self.__next_f.push([1,"..."])`) on every page — titles, slugs,
1614
+ * nesting, and order exactly as the live sidebar renders them. Only the
1615
+ * active tab's tree is populated in a given page's payload; other tabs are
1616
+ * listed with a `pointsTo` entry path, so each one costs a single extra
1617
+ * fetch.
1618
+ *
1619
+ * Always fetches directly (never Firecrawl): the flight chunks are only
1620
+ * guaranteed present in the raw SSR HTML, not in a rendered/processed DOM.
1621
+ * A dead entry URL (moved slug) is retried once from a known page, since
1622
+ * Fern soft-404s stale deep links.
1623
+ *
1624
+ * Returns { title, categories } or null if the site is not Fern or the
1625
+ * payload yields no usable tree. Any parse failure returns null so the
1626
+ * probe chain falls through to the existing routes.
1627
+ */
1628
+ async function tryFernNav(sourceUrl, knownPages) {
1629
+ try {
1630
+ const origin = new URL(sourceUrl).origin
1631
+ const looksFern = (html) => /<aside[^>]*id="fern-sidebar"/.test(html) || /<meta[^>]+name="generator"[^>]+content="[^"]*buildwithfern/i.test(html)
1632
+ let entryUrl = toBrowsableUrl(sourceUrl)
1633
+ let entryHtml = await fetchHtmlDirect(entryUrl)
1634
+ if (!looksFern(entryHtml)) {
1635
+ // A stale entry deep link 404s on Fern (the not-found boundary renders
1636
+ // with a truncated tree), so retry once from a known live page.
1637
+ const retry = knownPages.find((p) => normalizePath(p.url) !== normalizePath(sourceUrl))
1638
+ if (!retry) return null
1639
+ entryUrl = toBrowsableUrl(retry.url)
1640
+ entryHtml = await fetchHtmlDirect(entryUrl)
1641
+ if (!looksFern(entryHtml)) return null
1642
+ }
1643
+
1644
+ const byPath = new Map()
1645
+ for (const p of knownPages) byPath.set(normalizePath(p.url), p)
1646
+
1647
+ const ctx = { origin, byPath, seenPaths: new Set() }
1648
+ const categories = []
1649
+ const collect = (html, tabTitle) => {
1650
+ const blob = decodeFernFlightBlob(html)
1651
+ addFernCategories(categories, extractFernNavNodes(blob), { ...ctx, tabTitle })
1652
+ return blob
1653
+ }
1654
+
1655
+ const entryBlob = collect(entryHtml, null)
1656
+ const entryPath = normalizePath(entryUrl)
1657
+ const seenTabTargets = new Set()
1658
+ let tabFetches = 0
1659
+ for (const tab of extractFernTabs(entryBlob)) {
1660
+ if (seenTabTargets.has(tab.pointsTo)) continue
1661
+ seenTabTargets.add(tab.pointsTo)
1662
+ const tabUrl = `${origin}/${tab.pointsTo}`
1663
+ if (normalizePath(tabUrl) === entryPath) continue
1664
+ if (tabFetches >= FERN_TAB_FETCH_CAP) break
1665
+ tabFetches++
1666
+ const tabHtml = await fetchHtmlDirect(tabUrl)
1667
+ if (!tabHtml) continue
1668
+ collect(tabHtml, tab.title)
1669
+ }
1670
+
1671
+ const pageCount = categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
1672
+ if (pageCount === 0) return null
1673
+ return { title: null, categories }
1674
+ } catch {
1675
+ return null
1676
+ }
1677
+ }
1678
+
1679
+ function decodeFernFlightBlob(html) {
1680
+ const chunks = []
1681
+ const chunkRe = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g
1682
+ let m
1683
+ while ((m = chunkRe.exec(html)) !== null) {
1684
+ try {
1685
+ chunks.push(JSON.parse(`"${m[1]}"`))
1686
+ } catch { }
1687
+ }
1688
+ return chunks.join('')
1689
+ }
1690
+
1691
+ function extractBalancedObject(str, start) {
1692
+ let depth = 0
1693
+ let inString = false
1694
+ for (let i = start; i < str.length; i++) {
1695
+ const ch = str[i]
1696
+ if (inString) {
1697
+ if (ch === '\\') i++
1698
+ else if (ch === '"') inString = false
1699
+ } else if (ch === '"') inString = true
1700
+ else if (ch === '{') depth++
1701
+ else if (ch === '}') {
1702
+ depth--
1703
+ if (depth === 0) return str.slice(start, i + 1)
1704
+ }
1705
+ }
1706
+ return null
1707
+ }
1708
+
1709
+ /**
1710
+ * Pull top-level nav container/section objects out of a decoded flight blob.
1711
+ * The blob as a whole is not JSON — nav nodes are embedded mid-stream — so
1712
+ * each marker hit is balanced-brace extracted and parsed on its own. Matches
1713
+ * that fall inside an already-extracted object are its children and skipped.
1714
+ */
1715
+ function extractFernNavNodes(blob) {
1716
+ const nodes = []
1717
+ const markerRe = /\{"type":"(?:sidebarRoot|sidebarGroup|section|apiReference|apiPackage)"/g
1718
+ let consumedUpTo = -1
1719
+ let m
1720
+ while ((m = markerRe.exec(blob)) !== null) {
1721
+ if (m.index < consumedUpTo) continue
1722
+ const raw = extractBalancedObject(blob, m.index)
1723
+ if (!raw) continue
1724
+ consumedUpTo = m.index + raw.length
1725
+ try {
1726
+ nodes.push(JSON.parse(raw))
1727
+ } catch { }
1728
+ }
1729
+ return nodes
1730
+ }
1731
+
1732
+ function extractFernTabs(blob) {
1733
+ const tabs = []
1734
+ const tabRe = /\{"type":"tab",/g
1735
+ let m
1736
+ while ((m = tabRe.exec(blob)) !== null) {
1737
+ const raw = extractBalancedObject(blob, m.index)
1738
+ if (!raw) continue
1739
+ let tab
1740
+ try {
1741
+ tab = JSON.parse(raw)
1742
+ } catch {
1743
+ continue
1744
+ }
1745
+ const pointsTo = fernField(tab.pointsTo)
1746
+ if (!pointsTo) continue
1747
+ tabs.push({ title: fernField(tab.title), pointsTo })
1748
+ }
1749
+ return tabs
1750
+ }
1751
+
1752
+ // Absent fields serialize as the literal string "$undefined" in flight payloads.
1753
+ function fernField(value) {
1754
+ return typeof value === 'string' && value !== '$undefined' && value !== '' ? value : null
1755
+ }
1756
+
1757
+ function fernPageFromNode(node, ctx) {
1758
+ if (!node || typeof node !== 'object' || node.hidden === true) return null
1759
+ if (FERN_SKIP_NODE_TYPES.has(node.type)) return null
1760
+ const children = Array.isArray(node.children) ? node.children.map((c) => fernPageFromNode(c, ctx)).filter(Boolean) : []
1761
+ if (FERN_SECTION_NODE_TYPES.has(node.type)) {
1762
+ // A section owns a page only when overviewPageId is set — it then renders
1763
+ // at the section's own slug. pointsTo is a navigation alias to the
1764
+ // section's first descendant page, never a page of the section itself.
1765
+ const landing = fernField(node.overviewPageId) ? fernField(node.slug) : null
1766
+ const title = fernField(node.title) || 'Untitled'
1767
+ if (!landing) {
1768
+ if (children.length === 0) return null
1769
+ return { title, url: null, _emptyParent: true, _virtualPathSegs: [kebabCase(title) || 'group'], pages: children }
1770
+ }
1771
+ const landingUrl = `${ctx.origin}/${landing}`
1772
+ const known = ctx.byPath.get(normalizePath(landingUrl))
1773
+ return {
1774
+ title,
1775
+ url: known?.url || landingUrl,
1776
+ ...(children.length > 0 ? { pages: children } : {}),
1777
+ }
1778
+ }
1779
+ const slug = fernField(node.slug)
1780
+ if (slug) {
1781
+ const url = `${ctx.origin}/${slug}`
1782
+ const known = ctx.byPath.get(normalizePath(url))
1783
+ return {
1784
+ // The payload title is the sidebar label as rendered; llms.txt titles
1785
+ // come from page headings, which can differ from the nav.
1786
+ title: fernField(node.title) || known?.title || slug,
1787
+ url: known?.url || url,
1788
+ ...(known?.description ? { description: known.description } : {}),
1789
+ ...(children.length > 0 ? { pages: children } : {}),
1790
+ }
1791
+ }
1792
+ if (children.length === 1) return children[0]
1793
+ if (children.length > 1) return { title: fernField(node.title) || 'Untitled', url: null, pages: children }
1794
+ return null
1795
+ }
1796
+
1797
+ /**
1798
+ * Top-level sections become categories; untitled ones adopt the tab title so
1799
+ * two tabs' unnamed roots don't merge into one "Untitled" category with
1800
+ * colliding group slugs. Loose top-level pages (tabs without sections, e.g. a
1801
+ * two-page MCP tab) collapse into one category named after the tab. Pages
1802
+ * already claimed by an earlier category are dropped — the
1803
+ * payload repeats trees across fetches, and multi-product sites nest the
1804
+ * same API groups under both a product tab and the API-reference tab.
1805
+ */
1806
+ function addFernCategories(categories, nodes, ctx) {
1807
+ const roots = []
1808
+ for (const node of nodes) {
1809
+ if (node.type === 'sidebarRoot' || node.type === 'sidebarGroup') roots.push(...(node.children || []))
1810
+ else roots.push(node)
1811
+ }
1812
+ const loosePages = []
1813
+ for (const root of roots) {
1814
+ if (FERN_SECTION_NODE_TYPES.has(root.type)) {
1815
+ const section = fernPageFromNode(root, ctx)
1816
+ if (!section) continue
1817
+ if (!fernField(root.title) && ctx.tabTitle) section.title = ctx.tabTitle
1818
+ const pages = section.pages || []
1819
+ if (section.url && !pages.some((p) => p.url && normalizePath(p.url) === normalizePath(section.url))) {
1820
+ pages.unshift({ title: section.title, url: section.url })
1821
+ }
1822
+ const deduped = dedupeFernPages(pages, ctx.seenPaths)
1823
+ if (deduped.length > 0) categories.push({ title: section.title, pages: deduped })
1824
+ } else {
1825
+ const page = fernPageFromNode(root, ctx)
1826
+ if (page) loosePages.push(page)
1827
+ }
1828
+ }
1829
+ const deduped = dedupeFernPages(loosePages, ctx.seenPaths)
1830
+ if (deduped.length > 0) categories.push({ title: ctx.tabTitle || 'Documentation', pages: deduped })
1831
+ }
1832
+
1833
+ function dedupeFernPages(pages, seenPaths) {
1834
+ const out = []
1835
+ for (const p of pages) {
1836
+ const urlSeen = p.url ? seenPaths.has(normalizePath(p.url)) : false
1837
+ if (p.url && !urlSeen) seenPaths.add(normalizePath(p.url))
1838
+ const kids = p.pages ? dedupeFernPages(p.pages, seenPaths) : []
1839
+ if (kids.length === 0 && (urlSeen || !p.url)) continue
1840
+ const entry = { ...p, url: urlSeen ? null : p.url }
1841
+ if (kids.length > 0) entry.pages = kids
1842
+ else delete entry.pages
1843
+ out.push(entry)
1844
+ }
1845
+ return out
1846
+ }
1847
+
930
1848
  /**
931
1849
  * Score a parsed nav tree for "sidebar-likeness". A real docs sidebar has
932
1850
  * multiple section headers (hierarchy) and tens of links; secondary navs
@@ -961,6 +1879,63 @@ function isMonotonicAlpha(titles) {
961
1879
  return true
962
1880
  }
963
1881
 
1882
+ /**
1883
+ * Scan a rendered docs page for spec URLs embedded by the common API-reference
1884
+ * renderers — Swagger UI, Redoc, Scalar, Stoplight Elements. Returns absolute
1885
+ * http(s) URLs resolved against the page URL. Pure string scanning on HTML we
1886
+ * already fetched — zero extra requests.
1887
+ */
1888
+ function extractOasUrlsFromHtml(html, pageUrl) {
1889
+ const doc = String(html)
1890
+ const raws = []
1891
+
1892
+ // Swagger UI: `SwaggerUIBundle({ url: "…" })` or `urls: [{ url: "…" }, …]`.
1893
+ // Scanning a bounded window after each init call handles both shapes
1894
+ // without catastrophic backtracking.
1895
+ const swaggerInitRe = /SwaggerUI(?:Bundle)?\s*\(/g
1896
+ let m
1897
+ while ((m = swaggerInitRe.exec(doc)) !== null) {
1898
+ const windowText = doc.slice(m.index, m.index + 3000)
1899
+ const urlRe = /["']?\burl["']?\s*:\s*["']([^"']+)["']/g
1900
+ let u
1901
+ while ((u = urlRe.exec(windowText)) !== null) raws.push(u[1])
1902
+ }
1903
+
1904
+ const redocAttrRe = /<redoc\b[^>]*?\bspec-url\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
1905
+ while ((m = redocAttrRe.exec(doc)) !== null) raws.push(m[1] ?? m[2] ?? m[3])
1906
+ const redocInitRe = /Redoc\.init\s*\(\s*["']([^"']+)["']/g
1907
+ while ((m = redocInitRe.exec(doc)) !== null) raws.push(m[1])
1908
+
1909
+ // Scalar: <script id="api-reference" data-url="…"> — attr-order agnostic.
1910
+ const scriptTagRe = /<script\b[^>]*>/gi
1911
+ while ((m = scriptTagRe.exec(doc)) !== null) {
1912
+ const tag = m[0]
1913
+ if (!/\bid\s*=\s*["']api-reference["']/i.test(tag)) continue
1914
+ const u = /\bdata-url\s*=\s*["']([^"']+)["']/i.exec(tag)
1915
+ if (u) raws.push(u[1])
1916
+ }
1917
+
1918
+ const stoplightRe = /apiDescriptionUrl\s*=\s*"([^"]+)"/g
1919
+ while ((m = stoplightRe.exec(doc)) !== null) raws.push(m[1])
1920
+
1921
+ const out = []
1922
+ const seen = new Set()
1923
+ for (const raw of raws) {
1924
+ let abs
1925
+ try {
1926
+ abs = new URL(raw, pageUrl)
1927
+ } catch {
1928
+ continue
1929
+ }
1930
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') continue
1931
+ const href = abs.toString()
1932
+ if (seen.has(href)) continue
1933
+ seen.add(href)
1934
+ out.push(href)
1935
+ }
1936
+ return out
1937
+ }
1938
+
964
1939
  /**
965
1940
  * Fetch the source URL, find the `<nav>` or `<aside>` that contains the most
966
1941
  * links matching our known llms.txt URLs, and extract its heading/link
@@ -970,7 +1945,7 @@ function isMonotonicAlpha(titles) {
970
1945
  * renders its sidebar server-side as <nav>/<aside> with <h*> section headers.
971
1946
  * Returns null if coverage is too low to be useful.
972
1947
  */
973
- async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1948
+ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostics = {}, oasSink) {
974
1949
  // Index known pages by normalized pathname so we can match nav hrefs against them.
975
1950
  const byPath = new Map()
976
1951
  for (const p of knownPages) byPath.set(normalizePath(p.url), p)
@@ -994,6 +1969,9 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
994
1969
 
995
1970
  const html = await fetchHtml(url)
996
1971
  if (!html) return 0
1972
+ if (oasSink) {
1973
+ oasSink.push(...extractOasUrlsFromHtml(html, url).map((u) => ({ url: u, text: null, source: 'html' })))
1974
+ }
997
1975
 
998
1976
  const base = new URL(url)
999
1977
  let best = { score: -Infinity, count: 0, tree: null }
@@ -1106,7 +2084,10 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1106
2084
  const r0Start = Date.now()
1107
2085
  await visit(sourceUrl)
1108
2086
  const r0Ms = Date.now() - r0Start
1109
- if (categoryOrder.length === 0) return null
2087
+ if (categoryOrder.length === 0) {
2088
+ diagnostics.reason = 'no-categories-round0'
2089
+ return null
2090
+ }
1110
2091
 
1111
2092
  // Round 1 (parallel): visit pages so each branch has a chance to expose
1112
2093
  // its sub-items. Sidebars on most docs sites auto-expand the current
@@ -1123,9 +2104,9 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1123
2104
  const r1Urls = isDiscovery
1124
2105
  ? flattenTree(categoryOrder).slice(0, MAX_DISCOVERY_FETCHES)
1125
2106
  : categoryOrder
1126
- .map((c) => c.pages[0])
1127
- .filter(Boolean)
1128
- .map((p) => toBrowsableUrl(p.url))
2107
+ .map((c) => c.pages[0])
2108
+ .filter(Boolean)
2109
+ .map((p) => toBrowsableUrl(p.url))
1129
2110
  const r1Start = Date.now()
1130
2111
  // Firecrawl standard-plan concurrency is 10; 5 leaves headroom for retries.
1131
2112
  // Native HTTP can run hotter since we're hitting our own loopback.
@@ -1135,14 +2116,44 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1135
2116
  styles.dim(` ⏱ scrape breakdown: round0=${formatDuration(r0Ms)} round1=${formatDuration(r1Ms)} (${r1Urls.length} ${isDiscovery ? 'discovery' : 'category rep'} fetches)`),
1136
2117
  )
1137
2118
 
2119
+ // Prune login/account chrome now that crawling is done. On Stoplight sites
2120
+ // the "Sign in" → /auth link is the only seed on the bare landing page, so we
2121
+ // had to keep it discoverable to reach the real sidebar — but it's not a doc.
2122
+ const pruneAuthPages = (pages) => {
2123
+ let removed = 0
2124
+ for (let i = pages.length - 1; i >= 0; i--) {
2125
+ const page = pages[i]
2126
+ if (page.pages && page.pages.length > 0) removed += pruneAuthPages(page.pages)
2127
+ if (isAuthChromeUrl(page.url) && (!page.pages || page.pages.length === 0)) {
2128
+ matched.delete(normalizePath(page.url))
2129
+ pages.splice(i, 1)
2130
+ removed++
2131
+ }
2132
+ }
2133
+ return removed
2134
+ }
2135
+ for (const cat of categoryOrder) pruneAuthPages(cat.pages)
2136
+
1138
2137
  // Accept thresholds — looser in discovery mode (no llms.txt) where even a
1139
2138
  // single flat "Overview" bucket is better than nothing, stricter when we
1140
2139
  // have llms.txt to compare against.
1141
2140
  const categories = categoryOrder.filter((c) => c.pages.length > 0)
1142
2141
  if (isDiscovery) {
1143
- if (categories.length < 1 || matched.size < 5) return null
2142
+ if (categories.length < 1 || matched.size < 5) {
2143
+ diagnostics.reason = 'below-threshold'
2144
+ diagnostics.categories = categories.length
2145
+ diagnostics.matched = matched.size
2146
+ diagnostics.need = '≥1 category, ≥5 matched pages'
2147
+ return null
2148
+ }
1144
2149
  } else {
1145
- if (categories.length < 2 || matched.size < 10) return null
2150
+ if (categories.length < 2 || matched.size < 10) {
2151
+ diagnostics.reason = 'below-threshold'
2152
+ diagnostics.categories = categories.length
2153
+ diagnostics.matched = matched.size
2154
+ diagnostics.need = '≥2 categories, ≥10 matched pages'
2155
+ return null
2156
+ }
1146
2157
  }
1147
2158
  return { title: null, categories }
1148
2159
  }
@@ -1167,12 +2178,17 @@ function extractSidebarContainers(html) {
1167
2178
  const SIDEBAR_ATTR_RE =
1168
2179
  /\b(?:id|class|aria-label|data-testid)=(?:"[^"]*sidebar[^"]*"|'[^']*sidebar[^']*'|"[^"]*navigation[^"]*"|'[^']*navigation[^']*')|\brole=(?:"navigation"|'navigation')/i
1169
2180
 
2181
+ // Layout-wrapper modifiers like `Page--with-sidebar` / `has-sidebar` describe
2182
+ // an element that CONTAINS a sidebar, not one that IS a sidebar.
2183
+ const WRAPPER_MODIFIER_RE = /(?:^|[\s"'-])(?:with|has)-sidebar(?=[\s"']|$)/i
2184
+
1170
2185
  const out = []
1171
2186
  for (const tag of SIDEBAR_TAGS) {
1172
2187
  const openRe = new RegExp(`<${tag}\\b([^>]*)>`, 'gi')
1173
2188
  let m
1174
2189
  while ((m = openRe.exec(html)) !== null) {
1175
2190
  if (!SIDEBAR_ATTR_RE.test(m[1])) continue
2191
+ if (WRAPPER_MODIFIER_RE.test(m[1])) continue
1176
2192
  const inner = extractBalancedTag(html, tag, m.index + m[0].length)
1177
2193
  if (inner != null && inner.length > 100) out.push(inner)
1178
2194
  }
@@ -1322,7 +2338,7 @@ function parseNavBlock(blockHtml, base, byPath) {
1322
2338
  *
1323
2339
  * Returns { category, nonApiOrphans, mergedScrapedTitles }.
1324
2340
  */
1325
- function collectApiReferencePages(orphans, scraped) {
2341
+ function collectApiReferencePages(orphans, scraped, oasSink) {
1326
2342
  const API_PREFIX_RE = /^\/(api[-_]?reference|api|reference)(\/|$)/i
1327
2343
  const isApiUrl = (url) => {
1328
2344
  try {
@@ -1338,9 +2354,12 @@ function collectApiReferencePages(orphans, scraped) {
1338
2354
  const seenUrls = new Set()
1339
2355
  const push = (p) => {
1340
2356
  if (!p || !p.url) return
1341
- // Skip non-page assets like /api-reference/openapi.json that some
1342
- // llms.txt files list alongside real pages.
1343
- if (/\.(json|yaml|yml)$/i.test(p.url)) return
2357
+ // Non-page assets like /api-reference/openapi.json that some llms.txt
2358
+ // files list alongside real pages become spec candidates, not doc stubs.
2359
+ if (/\.(json|ya?ml)$/i.test(p.url)) {
2360
+ if (oasSink) oasSink.push({ url: p.url, text: p.title || null, source: 'link-sweep' })
2361
+ return
2362
+ }
1344
2363
  if (seenUrls.has(p.url)) return
1345
2364
  seenUrls.add(p.url)
1346
2365
  apiPages.push(p)
@@ -1383,7 +2402,7 @@ function collectApiReferencePages(orphans, scraped) {
1383
2402
  let segs = []
1384
2403
  try {
1385
2404
  segs = new URL(p.url).pathname.split('/').filter(Boolean)
1386
- } catch {}
2405
+ } catch { }
1387
2406
  // segs[0] is the api-prefix itself; segs[1] (if any) is the resource.
1388
2407
  const resource = segs.length >= 3 ? segs[1] : null
1389
2408
  if (!resource) {
@@ -1430,7 +2449,7 @@ function collectApiReferencePages(orphans, scraped) {
1430
2449
  * Uses distinctive titles — just "Docs" or "Reference" — rather than the raw
1431
2450
  * segment name. Falls back to "Other" when the path has no usable type.
1432
2451
  */
1433
- function bucketOrphansByPathType(orphans, scraped) {
2452
+ function bucketOrphansByPathType(orphans, scraped, oasSink) {
1434
2453
  const TYPE_TITLES = {
1435
2454
  reference: 'API Reference',
1436
2455
  api: 'API Reference',
@@ -1439,9 +2458,9 @@ function bucketOrphansByPathType(orphans, scraped) {
1439
2458
  endpoints: 'API Reference',
1440
2459
  endpoint: 'API Reference',
1441
2460
  changelog: 'Changelog',
1442
- release: 'Release Notes',
1443
- releases: 'Release Notes',
1444
- 'release-notes': 'Release Notes',
2461
+ release: 'Changelog',
2462
+ releases: 'Changelog',
2463
+ 'release-notes': 'Changelog',
1445
2464
  recipes: 'Recipes',
1446
2465
  recipe: 'Recipes',
1447
2466
  guides: 'Guides',
@@ -1478,9 +2497,12 @@ function bucketOrphansByPathType(orphans, scraped) {
1478
2497
  continue
1479
2498
  }
1480
2499
  const pathname = url.pathname
1481
- // Skip OAS spec endpoints — `/api-reference/openapi.json` etc. aren't
1482
- // documentation pages and shouldn't become stubs.
1483
- if (/\.(json|ya?ml)$/i.test(pathname)) continue
2500
+ // OAS spec endpoints — `/api-reference/openapi.json` etc. aren't
2501
+ // documentation pages; capture them as spec candidates instead of stubs.
2502
+ if (/\.(json|ya?ml)$/i.test(pathname)) {
2503
+ if (oasSink) oasSink.push({ url: p.url, text: p.title || null, source: 'link-sweep' })
2504
+ continue
2505
+ }
1484
2506
 
1485
2507
  const segs = pathname.split('/').filter(Boolean)
1486
2508
  let type = null
@@ -1534,43 +2556,42 @@ function titleCase(s) {
1534
2556
 
1535
2557
  /**
1536
2558
  * Walk all scraped pages (including nested sub-pages) and move any whose URL
1537
- * contains a strong reference segment (`/api-reference/`, `/endpoints/`, etc.)
1538
- * into a single "API Reference" category. Some docs sites (e.g. greenflash.ai)
1539
- * spotlight a handful of endpoints under "Developers" in the sidebar while the
1540
- * bulk of endpoints live under a separate API Reference section — we favor
1541
- * the URL-path signal over the sidebar placement so all reference pages land
1542
- * together in `reference/` after staging.
2559
+ * carries one of `segmentRe`'s path segments into a single category,
2560
+ * flattening sub-pages as it goes. Shared backbone for the API Reference and
2561
+ * Changelog sweeps: both favor the URL-path signal over sidebar placement, so
2562
+ * e.g. `/api-reference/*` or `/changelog/*` pages land together in their own
2563
+ * category even when a site's sidebar spotlights a few of them elsewhere.
1543
2564
  *
1544
- * Returns the number of pages relocated.
2565
+ * `categoryRe` matches an existing category title to merge into (so we don't
2566
+ * create a duplicate); when none matches, a fresh `{ title: defaultTitle }`
2567
+ * category is appended. Returns the number of pages relocated.
1545
2568
  */
1546
- function reclassifyReferencePages(scraped) {
1547
- const REFERENCE_SEGMENT = /^(api[-_]?reference|endpoints?)$/i
2569
+ function reclassifyPagesByUrlSegment(scraped, { segmentRe, categoryRe, defaultTitle }) {
1548
2570
  const normTitle = (t) =>
1549
2571
  String(t || '')
1550
2572
  .replace(INVISIBLE_CHARS, '')
1551
2573
  .trim()
1552
2574
  .toLowerCase()
1553
2575
 
1554
- const looksLikeRefUrl = (url) => {
2576
+ const urlHasSegment = (url) => {
1555
2577
  try {
1556
2578
  const segs = new URL(url).pathname.split('/').filter(Boolean)
1557
- return segs.some((s) => REFERENCE_SEGMENT.test(s))
2579
+ return segs.some((s) => segmentRe.test(s))
1558
2580
  } catch {
1559
2581
  return false
1560
2582
  }
1561
2583
  }
1562
2584
 
1563
- // Find (or create) the canonical API Reference category. Prefer an existing
1564
- // one with a reference-shaped title so we don't end up with duplicates.
1565
- let refCat = scraped.categories.find((c) => /^(api[ -]?reference|reference|api|endpoints?)$/i.test(normTitle(c.title).replace(/\s+/g, ' ')))
1566
- const existedBefore = Boolean(refCat)
2585
+ // Find (or create) the canonical destination category. Prefer an existing
2586
+ // one with a matching title so we don't end up with duplicates.
2587
+ let destCat = scraped.categories.find((c) => categoryRe.test(normTitle(c.title).replace(/\s+/g, ' ')))
1567
2588
 
1568
2589
  const collected = []
1569
2590
  const filterPages = (pages) => {
1570
2591
  const kept = []
1571
2592
  for (const p of pages || []) {
1572
- if (looksLikeRefUrl(p.url)) {
1573
- // Flatten sub-pages when relocating — API Reference is a flat list.
2593
+ if (urlHasSegment(p.url)) {
2594
+ // Flatten sub-pages when relocating — these sections are flat lists.
1574
2595
  collectFlat(p, collected)
1575
2596
  continue
1576
2597
  }
@@ -1580,32 +2601,156 @@ function reclassifyReferencePages(scraped) {
1580
2601
  return kept
1581
2602
  }
1582
2603
 
1583
- // Never pull pages out of the reference category itself.
2604
+ // Never pull pages out of the destination category itself.
1584
2605
  for (const cat of scraped.categories) {
1585
- if (cat === refCat) continue
2606
+ if (cat === destCat) continue
1586
2607
  cat.pages = filterPages(cat.pages)
1587
2608
  }
1588
2609
 
1589
2610
  if (collected.length === 0) return 0
1590
2611
 
1591
- if (!refCat) {
1592
- refCat = { title: 'API Reference', pages: [] }
1593
- scraped.categories.push(refCat)
2612
+ if (!destCat) {
2613
+ destCat = { title: defaultTitle, pages: [] }
2614
+ scraped.categories.push(destCat)
1594
2615
  }
1595
- // Dedupe against anything already in the reference category.
1596
- const seen = new Set(refCat.pages.map((p) => normalizePath(p.url)))
2616
+ // Dedupe against anything already in the destination category.
2617
+ const seen = new Set(destCat.pages.map((p) => normalizePath(p.url)))
1597
2618
  for (const p of collected) {
1598
2619
  const key = normalizePath(p.url)
1599
2620
  if (seen.has(key)) continue
1600
2621
  seen.add(key)
1601
- refCat.pages.push(p)
2622
+ destCat.pages.push(p)
2623
+ }
2624
+
2625
+ // Drop now-empty categories (other than the one we may have just created).
2626
+ scraped.categories = scraped.categories.filter((c) => c === destCat || (c.pages && c.pages.length > 0))
2627
+
2628
+ return collected.length
2629
+ }
2630
+
2631
+ /**
2632
+ * Sweep `/api-reference/*` and `/endpoints/*` pages into one "API Reference"
2633
+ * category. Some docs sites (e.g. greenflash.ai) spotlight a handful of
2634
+ * endpoints under "Developers" in the sidebar while the bulk of endpoints
2635
+ * live under a separate API Reference section — this lands them all in
2636
+ * `reference/` after staging. Returns the number of pages relocated.
2637
+ */
2638
+ const API_REFERENCE_URL_SEGMENT_RE = /^(api[-_]?reference|endpoints?)$/i
2639
+
2640
+ function urlIsApiReference(url) {
2641
+ try {
2642
+ const segs = new URL(url).pathname.split('/').filter(Boolean)
2643
+ return segs.some((s) => API_REFERENCE_URL_SEGMENT_RE.test(s))
2644
+ } catch {
2645
+ return false
2646
+ }
2647
+ }
2648
+
2649
+ function reclassifyReferencePages(scraped) {
2650
+ return reclassifyPagesByUrlSegment(scraped, {
2651
+ segmentRe: API_REFERENCE_URL_SEGMENT_RE,
2652
+ categoryRe: /^(api[ -]?reference|reference|api|endpoints?)$/i,
2653
+ defaultTitle: 'API Reference',
2654
+ })
2655
+ }
2656
+
2657
+ // Anchored half covers ambiguous bare keywords (release, releases, versioned
2658
+ // release-2026-2) — those need to be the whole segment so we don't sweep
2659
+ // `/release-pipeline/` etc. Unanchored half covers the unambiguous keywords
2660
+ // (changelog, release-notes, whats-new), which we catch as substrings so
2661
+ // slugs like `/docs/changelog-javascript-agent` and `/docs/ios-sdk-changelog`
2662
+ // get pulled in too.
2663
+ const CHANGELOG_URL_SEGMENT_RE = /(?:^(?:releases?|release[-_]?v?\d[\w.-]*)$)|(?:changelog|change[-_]?log|release[-_]?notes?|whats?[-_]?new)/i
2664
+ const CHANGELOG_CATEGORY_TITLE_RE = /^(changelog|change ?log|release ?notes?|releases?|what'?s ?new)$/i
2665
+
2666
+ function urlIsChangelog(url) {
2667
+ try {
2668
+ const segs = new URL(url).pathname.split('/').filter(Boolean)
2669
+ return segs.some((s) => CHANGELOG_URL_SEGMENT_RE.test(s))
2670
+ } catch {
2671
+ return false
2672
+ }
2673
+ }
2674
+
2675
+ /**
2676
+ * Pull changelog items OUT of llms.parsed.sections in place. Done before the
2677
+ * organize pipeline runs so AI / clustering never sees them — they get
2678
+ * re-attached as a dedicated Changelog category at the end via
2679
+ * injectChangelogCategory. Sections that go empty afterwards are filtered out
2680
+ * naturally by `usableSections` downstream.
2681
+ *
2682
+ * Returns the extracted items in `{ title, url, description? }` shape.
2683
+ */
2684
+ function extractChangelogFromSections(sections) {
2685
+ const extracted = []
2686
+ for (const section of sections || []) {
2687
+ const kept = []
2688
+ for (const item of section.items || []) {
2689
+ if (urlIsChangelog(item.url)) {
2690
+ extracted.push({
2691
+ title: item.text,
2692
+ url: item.url,
2693
+ ...(item.description ? { description: item.description } : {}),
2694
+ })
2695
+ } else {
2696
+ kept.push(item)
2697
+ }
2698
+ }
2699
+ section.items = kept
2700
+ }
2701
+ return extracted
2702
+ }
2703
+
2704
+ /**
2705
+ * Split a flat known-URL list into changelog vs. the rest. Used for the
2706
+ * sitemap-only fallback, where there's no llms.txt section structure to mutate.
2707
+ */
2708
+ function partitionChangelogFromKnownUrls(knownUrls) {
2709
+ const extracted = []
2710
+ const kept = []
2711
+ for (const p of knownUrls || []) {
2712
+ if (urlIsChangelog(p.url)) extracted.push(p)
2713
+ else kept.push(p)
1602
2714
  }
2715
+ return { extracted, kept }
2716
+ }
1603
2717
 
1604
- // Drop now-empty categories (other than the reference one we may have just created).
1605
- scraped.categories = scraped.categories.filter((c) => c === refCat || (c.pages && c.pages.length > 0))
2718
+ /**
2719
+ * Attach pre-extracted changelog items as a dedicated Changelog category on
2720
+ * the final organized tree. Merges into an existing Changelog-titled category
2721
+ * if one is already present (e.g. the scrape path's reclassifier already built
2722
+ * one); otherwise creates a fresh one. Dedupe is by normalized URL path.
2723
+ */
2724
+ function injectChangelogCategory(organized, items) {
2725
+ if (!items || items.length === 0) return
2726
+ organized.categories = organized.categories || []
2727
+ let dest = organized.categories.find((c) => CHANGELOG_CATEGORY_TITLE_RE.test(String(c.title || '').trim()))
2728
+ if (!dest) {
2729
+ dest = { title: 'Changelog', icon: null, pages: [] }
2730
+ organized.categories.push(dest)
2731
+ }
2732
+ const seen = new Set((dest.pages || []).map((p) => normalizePath(p.url)))
2733
+ for (const item of items) {
2734
+ const key = normalizePath(item.url)
2735
+ if (seen.has(key)) continue
2736
+ seen.add(key)
2737
+ dest.pages.push(item)
2738
+ }
2739
+ }
1606
2740
 
1607
- // If the category existed before but the relocation was a no-op, surface 0.
1608
- return existedBefore ? collected.length : collected.length
2741
+ /**
2742
+ * Sweep `/changelog/*`, `/release-notes/*` and `/releases/*` pages into one
2743
+ * "Changelog" category, wherever the site's sidebar happened to place them.
2744
+ * That category routes to `docs/Changelog/`; `--separate-changelog` later
2745
+ * relocates it to a top-level `changelog/` dir. Returns the number of pages
2746
+ * relocated.
2747
+ */
2748
+ function reclassifyChangelogPages(scraped) {
2749
+ return reclassifyPagesByUrlSegment(scraped, {
2750
+ segmentRe: CHANGELOG_URL_SEGMENT_RE,
2751
+ categoryRe: CHANGELOG_CATEGORY_TITLE_RE,
2752
+ defaultTitle: 'Changelog',
2753
+ })
1609
2754
  }
1610
2755
 
1611
2756
  function collectFlat(page, out) {
@@ -1619,71 +2764,168 @@ function collectFlat(page, out) {
1619
2764
  }
1620
2765
  }
1621
2766
 
2767
+ function countUrlPagesDeep(pages) {
2768
+ let n = 0
2769
+ for (const p of pages || []) {
2770
+ if (p.url) n++
2771
+ if (p.pages && p.pages.length > 0) n += countUrlPagesDeep(p.pages)
2772
+ }
2773
+ return n
2774
+ }
2775
+
2776
+ function collectUrlPagesDeep(pages, out = []) {
2777
+ for (const p of pages || []) {
2778
+ if (p.url) out.push(p)
2779
+ if (p.pages && p.pages.length > 0) collectUrlPagesDeep(p.pages, out)
2780
+ }
2781
+ return out
2782
+ }
2783
+
2784
+ // urlTrieSegs is imported from ../utils/url-segs.js
2785
+
1622
2786
  /**
1623
- * Re-parent URL-bearing siblings by URL path so a page like `/foo/bar`
1624
- * becomes a child of `/foo` when both appear in the same list.
2787
+ * Re-parent URL-bearing siblings by URL path so descendants nest under their
2788
+ * URL prefix even when no page exists at every intermediate segment. We build
2789
+ * a trie from each page's URL path and render it back out as a page tree:
2790
+ *
2791
+ * - The shared top-level prefix is elided (the category folder is its
2792
+ * implicit container — e.g. /docs/ai/evals collapses into "Pydantic Evals").
2793
+ * - Below the elision point, every segment becomes an entry: a real page
2794
+ * where one exists, otherwise an empty-parent stub (folder-icon, no URL).
2795
+ *
2796
+ * `anchorSegs` is the parent page's URL segments during recursion; when set,
2797
+ * we walk the trie to that exact depth instead of eliding to the natural
2798
+ * branch point, so a missing intermediate like `b` in `/a → /a/b/c, /a/b/d`
2799
+ * still renders as an empty parent under `/a`.
1625
2800
  *
1626
2801
  * @example
1627
2802
  * nestByUrlHierarchy([
1628
- * { title: 'CLI', url: 'https://x.com/getting-started/cli' },
1629
- * { title: 'Overview', url: 'https://x.com/getting-started/cli/overview' },
2803
+ * { title: 'Hello', url: 'https://x.com/main/sub1/sub2/sub3/hello' },
2804
+ * { title: 'World', url: 'https://x.com/main/sub1/sub2/sub3/world' },
2805
+ * { title: 'Sub 1', url: 'https://x.com/main/sub1' },
1630
2806
  * ])
1631
2807
  * // [
1632
- * // { title: 'CLI', url: '…/cli', pages: [
1633
- * // { title: 'Overview', url: '…/cli/overview' },
2808
+ * // { title: 'Sub 1', url: '…/main/sub1', pages: [
2809
+ * // { title: 'Sub2', _emptyParent: true, pages: [
2810
+ * // { title: 'Sub3', _emptyParent: true, pages: [
2811
+ * // { title: 'Hello', url: '…/main/sub1/sub2/sub3/hello' },
2812
+ * // { title: 'World', url: '…/main/sub1/sub2/sub3/world' },
2813
+ * // ]},
2814
+ * // ]},
1634
2815
  * // ]},
1635
2816
  * // ]
1636
2817
  */
1637
- function nestByUrlHierarchy(pages) {
2818
+ function nestByUrlHierarchy(pages, anchorSegs = null) {
1638
2819
  if (!pages || pages.length === 0) return pages
1639
2820
 
1640
- // Recurse first so group-only / already-nested subtrees get re-nested too.
1641
- for (const p of pages) {
1642
- if (p.pages && p.pages.length > 0) p.pages = nestByUrlHierarchy(p.pages)
1643
- }
1644
-
1645
- // Need at least two URL-bearing siblings to form a parent/child pair.
1646
- const byPath = new Map()
2821
+ // Recurse first so subtrees that came in pre-nested (e.g. from the scraper)
2822
+ // get re-nested against their parent's URL as anchor.
1647
2823
  for (const p of pages) {
1648
- if (!p.url) continue
1649
- const k = normalizePath(p.url)
1650
- if (!byPath.has(k)) byPath.set(k, p)
2824
+ if (p.pages && p.pages.length > 0) {
2825
+ let nextAnchor = anchorSegs
2826
+ if (p._virtualPathSegs) nextAnchor = p._virtualPathSegs
2827
+ else if (p.url) {
2828
+ const s = urlTrieSegs(p.url)
2829
+ if (s) nextAnchor = s
2830
+ }
2831
+ p.pages = nestByUrlHierarchy(p.pages, nextAnchor)
2832
+ }
1651
2833
  }
1652
- if (byPath.size < 2) return pages
1653
2834
 
1654
- const result = []
2835
+ const urlEntries = []
2836
+ const groupOnly = []
1655
2837
  for (const p of pages) {
1656
2838
  if (!p.url) {
1657
- result.push(p)
2839
+ groupOnly.push(p)
1658
2840
  continue
1659
2841
  }
1660
- let segs
1661
- try {
1662
- segs = new URL(p.url).pathname.split('/').filter(Boolean)
1663
- } catch {
1664
- result.push(p)
1665
- continue
1666
- }
1667
- let parent = null
1668
- for (let depth = segs.length - 1; depth >= 1; depth--) {
1669
- const key = ('/' + segs.slice(0, depth).join('/'))
1670
- .toLowerCase()
1671
- .replace(/\.(md|mdx|html?)$/i, '')
1672
- .replace(/\/$/, '')
1673
- const candidate = byPath.get(key)
1674
- if (candidate && candidate !== p) {
1675
- parent = candidate
1676
- break
2842
+ const segs = urlTrieSegs(p.url)
2843
+ // Zero-segment URLs (origin-only links like `https://build.example.com/`)
2844
+ // have no path to nest by — if we inserted them into the trie they'd land
2845
+ // at the root node and the container walk would render them as the
2846
+ // top-level entry with every other page nested beneath them. Treat them
2847
+ // like group-only pages so they just sit alongside the real tree.
2848
+ if (segs && segs.length > 0) urlEntries.push({ page: p, segs })
2849
+ else groupOnly.push(p)
2850
+ }
2851
+
2852
+ if (urlEntries.length === 0) return pages
2853
+ if (urlEntries.length === 1 && groupOnly.length === 0) return pages
2854
+
2855
+ // Build the URL trie. Each node tracks its own segment, the page at that
2856
+ // exact path (if any), insertion-ordered children, and the segment path
2857
+ // from root (used as the slug source for empty parents).
2858
+ const root = { segment: '', page: null, children: new Map(), segs: [] }
2859
+ for (const { page, segs } of urlEntries) {
2860
+ let node = root
2861
+ for (let i = 0; i < segs.length; i++) {
2862
+ const segLower = segs[i].toLowerCase()
2863
+ if (!node.children.has(segLower)) {
2864
+ node.children.set(segLower, { segment: segs[i], page: null, children: new Map(), segs: segs.slice(0, i + 1) })
1677
2865
  }
2866
+ node = node.children.get(segLower)
1678
2867
  }
1679
- if (parent) {
1680
- if (!parent.pages) parent.pages = []
1681
- parent.pages.push(p)
2868
+ if (!node.page) node.page = page
2869
+ }
2870
+
2871
+ // Find the trie node that represents the "container" for this level.
2872
+ // - With an anchor: that's the parent page's exact depth; we don't elide
2873
+ // past it, so intermediate segments below the anchor become empties.
2874
+ // - Without: walk down while there's only one no-page child (the shared
2875
+ // prefix). Stop at the first node that either has a page or branches.
2876
+ let container = root
2877
+ if (anchorSegs && anchorSegs.length > 0) {
2878
+ for (let i = 0; i < anchorSegs.length; i++) {
2879
+ const seg = anchorSegs[i].toLowerCase()
2880
+ if (container.children.has(seg)) container = container.children.get(seg)
2881
+ else break
2882
+ }
2883
+ } else {
2884
+ while (container.children.size === 1 && !container.page) {
2885
+ container = container.children.values().next().value
2886
+ }
2887
+ }
2888
+
2889
+ function nodeToPage(node) {
2890
+ let outPage
2891
+ if (node.page) {
2892
+ outPage = node.page
1682
2893
  } else {
1683
- result.push(p)
2894
+ const rawSeg = node.segment
2895
+ const cleanedSeg = stripSegmentExtensions(rawSeg).replace(/^\d+[-_.]/, '') || rawSeg
2896
+ outPage = {
2897
+ title: titleCase(cleanedSeg),
2898
+ _emptyParent: true,
2899
+ _virtualPathSegs: node.segs,
2900
+ pages: [],
2901
+ }
2902
+ }
2903
+ if (node.children.size > 0) {
2904
+ const childPages = []
2905
+ for (const c of node.children.values()) childPages.push(nodeToPage(c))
2906
+ // Preserve any pre-existing nested children whose URLs aren't already
2907
+ // represented in the trie (group-only containers from the scraper, etc.).
2908
+ const existing = outPage.pages || []
2909
+ const trieUrls = new Set()
2910
+ const collect = (p) => {
2911
+ if (p.url) trieUrls.add(normalizePath(p.url))
2912
+ for (const c of p.pages || []) collect(c)
2913
+ }
2914
+ for (const cp of childPages) collect(cp)
2915
+ const preservedExisting = existing.filter((e) => !e.url || !trieUrls.has(normalizePath(e.url)))
2916
+ outPage.pages = [...childPages, ...preservedExisting]
1684
2917
  }
2918
+ return outPage
1685
2919
  }
1686
- return result
2920
+
2921
+ const trieResult = []
2922
+ if (container.page) {
2923
+ trieResult.push(nodeToPage(container))
2924
+ } else {
2925
+ for (const c of container.children.values()) trieResult.push(nodeToPage(c))
2926
+ }
2927
+
2928
+ return [...trieResult, ...groupOnly]
1687
2929
  }
1688
2930
 
1689
2931
  /**
@@ -1697,10 +2939,10 @@ function slotOrphansByPath(scraped, knownPages) {
1697
2939
  const matched = new Set()
1698
2940
  const pathToCategory = new Map() // normalizedPath → category
1699
2941
  for (const cat of scraped.categories) {
1700
- for (const p of cat.pages) {
2942
+ for (const p of collectUrlPagesDeep(cat.pages)) {
1701
2943
  const norm = normalizePath(p.url)
1702
2944
  matched.add(norm)
1703
- pathToCategory.set(norm, cat)
2945
+ if (!pathToCategory.has(norm)) pathToCategory.set(norm, cat)
1704
2946
  }
1705
2947
  }
1706
2948
 
@@ -1800,6 +3042,84 @@ async function fetchHtmlDirect(url) {
1800
3042
  }
1801
3043
  }
1802
3044
 
3045
+ /**
3046
+ * Download a candidate spec URL as raw bytes. Returns a Buffer, or null on
3047
+ * non-OK / network error. No parsing or validation — the runner is the OAS gate.
3048
+ */
3049
+ async function fetchSpecBytes(url) {
3050
+ try {
3051
+ const res = await fetch(url, {
3052
+ redirect: 'follow',
3053
+ headers: {
3054
+ 'User-Agent': 'readme-cli-import',
3055
+ Accept: 'application/json, application/x-yaml, application/yaml, text/yaml, text/plain',
3056
+ },
3057
+ })
3058
+ if (!res.ok) return null
3059
+ return Buffer.from(await res.arrayBuffer())
3060
+ } catch {
3061
+ return null
3062
+ }
3063
+ }
3064
+
3065
+ // Extension for an extensionless candidate (e.g. /v3/api-docs), sniffed from
3066
+ // the body. Defaults to .json — the runner tolerates a mislabeled extension.
3067
+ function sniffSpecExtension(text) {
3068
+ try {
3069
+ JSON.parse(text)
3070
+ return '.json'
3071
+ } catch {
3072
+ return OAS_TOP_LEVEL_KEY_RE.test(String(text).slice(0, 64 * 1024)) ? '.yaml' : '.json'
3073
+ }
3074
+ }
3075
+
3076
+ /**
3077
+ * Download the captured candidate-spec URLs into `<stagingDir>/oas/`. Probe
3078
+ * hits carry their prefetched bytes in `item.body`; everything else is
3079
+ * fetched here. Bodies that look like HTML are skipped so the runner's
3080
+ * user-visible candidate count stays honest — all other validation happens in
3081
+ * the runner. Filenames come from the URL basename, keeping a .json/.yaml/.yml
3082
+ * extension when present and sniffing one otherwise (collisions get a numeric
3083
+ * suffix); the runner de-dups by content, so identical bytes under different
3084
+ * names are handled there. Returns the count of files written.
3085
+ */
3086
+ async function downloadOasSpecs(oasJsonUrls, stagingDir) {
3087
+ const oasDir = path.join(stagingDir, 'oas')
3088
+ fs.mkdirSync(oasDir, { recursive: true })
3089
+ const usedNames = new Set()
3090
+ let written = 0
3091
+ for (const item of oasJsonUrls) {
3092
+ const buf = item.body || (await fetchSpecBytes(item.url))
3093
+ if (!buf) {
3094
+ styles.info(styles.dim(` oas: skipped ${item.url} (download failed)`))
3095
+ continue
3096
+ }
3097
+ const text = buf.toString('utf8')
3098
+ if (/^\s*</.test(text)) {
3099
+ styles.info(styles.dim(` oas: skipped ${item.url} (looks like HTML)`))
3100
+ continue
3101
+ }
3102
+ let base
3103
+ try {
3104
+ base = path.basename(new URL(item.url).pathname) || 'openapi'
3105
+ } catch {
3106
+ base = 'openapi'
3107
+ }
3108
+ base = base.replace(/[^a-zA-Z0-9._-]/g, '-')
3109
+ if (!/\.(json|ya?ml)$/i.test(base)) base += sniffSpecExtension(text)
3110
+ let name = base
3111
+ let n = 2
3112
+ while (usedNames.has(name)) {
3113
+ name = base.replace(/\.(json|ya?ml)$/i, `-${n}$&`)
3114
+ n++
3115
+ }
3116
+ usedNames.add(name)
3117
+ fs.writeFileSync(path.join(oasDir, name), buf)
3118
+ written++
3119
+ }
3120
+ return written
3121
+ }
3122
+
1803
3123
  /**
1804
3124
  * Firecrawl-backed HTML loader. Firecrawl runs a real browser, waits for
1805
3125
  * hydration, and returns the rendered DOM — which is what we need for sites
@@ -1900,14 +3220,29 @@ function urlNamespace(url) {
1900
3220
  function clusterByUrlPath(pages) {
1901
3221
  if (!pages || pages.length < 3) return null
1902
3222
 
1903
- const parts = pages.map((p) => {
3223
+ // Pages with no path segments (e.g. an origin-only URL like
3224
+ // `https://build.example.com/`) can't participate in segment-based
3225
+ // clustering. A handful are fine — we'll fold them into the first cluster
3226
+ // as a place to land — but if more than 20% of the input is rootless,
3227
+ // the source isn't really a hierarchical doc set and clustering won't
3228
+ // produce a meaningful tree. Bail in that case so the caller falls back.
3229
+ const noSegPages = []
3230
+ const clusterable = []
3231
+ const parts = []
3232
+ for (const p of pages) {
3233
+ let segs = []
1904
3234
  try {
1905
- return new URL(p.url).pathname.split('/').filter(Boolean)
1906
- } catch {
1907
- return []
3235
+ segs = new URL(p.url).pathname.split('/').filter(Boolean)
3236
+ } catch { }
3237
+ if (segs.length === 0) {
3238
+ noSegPages.push(p)
3239
+ } else {
3240
+ clusterable.push(p)
3241
+ parts.push(segs)
1908
3242
  }
1909
- })
1910
- if (parts.some((pp) => pp.length === 0)) return null
3243
+ }
3244
+ if (noSegPages.length / pages.length > 0.2) return null
3245
+ if (clusterable.length < 3) return null
1911
3246
 
1912
3247
  // Longest common prefix depth.
1913
3248
  let commonDepth = 0
@@ -1920,13 +3255,13 @@ function clusterByUrlPath(pages) {
1920
3255
  // The segment right after the common base is the category key.
1921
3256
  const keyIdx = commonDepth
1922
3257
  const byKey = new Map()
1923
- for (let i = 0; i < pages.length; i++) {
3258
+ for (let i = 0; i < clusterable.length; i++) {
1924
3259
  const key = parts[i][keyIdx]
1925
3260
  // Skip pages that have no segment at the cluster index (they're AT the
1926
3261
  // common base — those would become their own "index"-like category).
1927
3262
  if (!key) continue
1928
3263
  if (!byKey.has(key)) byKey.set(key, [])
1929
- byKey.get(key).push(pages[i])
3264
+ byKey.get(key).push(clusterable[i])
1930
3265
  }
1931
3266
 
1932
3267
  // Reject weak clusterings: need at least 2 groups AND at least one group
@@ -1937,7 +3272,7 @@ function clusterByUrlPath(pages) {
1937
3272
 
1938
3273
  // Preserve first-appearance order so the sidebar reflects source order.
1939
3274
  const firstSeen = new Map()
1940
- pages.forEach((p, i) => {
3275
+ clusterable.forEach((p, i) => {
1941
3276
  const key = parts[i][keyIdx]
1942
3277
  if (key && !firstSeen.has(key)) firstSeen.set(key, i)
1943
3278
  })
@@ -1948,28 +3283,173 @@ function clusterByUrlPath(pages) {
1948
3283
  pages: byKey.get(key),
1949
3284
  }))
1950
3285
 
1951
- // A cluster with exactly one top-level page is NOT a real category — it's
1952
- // a parent page with children wrapped in a pseudo-category label. Categories
1953
- // are grouping labels with no content of their own; parent pages have
1954
- // content AND children. Collect those singletons into a shared
1955
- // "Documentation" bucket so they're siblings at the top level, each with
1956
- // their own sub-tree intact.
1957
- const multipageClusters = rawClusters.filter((c) => c.pages.length >= 2)
1958
- const singletonPages = rawClusters.filter((c) => c.pages.length === 1).flatMap((c) => c.pages)
1959
-
1960
- const out = []
1961
- if (singletonPages.length > 0) {
1962
- out.push({ title: 'Documentation', pages: singletonPages })
3286
+ // Classify each cluster as either a real category or an "Other
3287
+ // Documentation" misfit. A real category needs at least 2 pages — a
3288
+ // "category" with a single file in it just adds a level of nesting
3289
+ // without adding structure. Pydantic's `/docs/logfire/get-started` is
3290
+ // the motivating example: as the only `/docs/logfire/*` URL it
3291
+ // shouldn't get its own top-level "Logfire" folder. Lone pages land in
3292
+ // Other Documentation where they sit alongside other low-volume material.
3293
+ const realCategories = []
3294
+ const misfitPages = []
3295
+ for (const cluster of rawClusters) {
3296
+ if (cluster.pages.length >= 2) {
3297
+ realCategories.push(cluster)
3298
+ continue
3299
+ }
3300
+ misfitPages.push(cluster.pages[0])
1963
3301
  }
1964
- out.push(...multipageClusters)
1965
3302
 
1966
- // If we didn't actually produce any multi-page cluster, clustering added no
1967
- // value — every page was a singleton and we'd just have renamed Overview.
1968
- // Tell the caller to stick with the original flat shape.
1969
- if (multipageClusters.length === 0) return null
3303
+ // If clustering produced no real categories, it added no value — tell the
3304
+ // caller to fall back rather than hand back a lone misfit bucket.
3305
+ if (realCategories.length === 0) return null
3306
+
3307
+ const out = [...realCategories]
3308
+
3309
+ // "Other Documentation" goes last so it never displaces a real category.
3310
+ // It absorbs both standalone-page singletons and zero-segment URLs (those
3311
+ // can't participate in segment-based clustering at all but the >20% guard
3312
+ // above already proved they're a minority).
3313
+ const otherDocs = [...misfitPages, ...noSegPages]
3314
+ if (otherDocs.length > 0) {
3315
+ out.push({ title: 'Other Documentation', pages: otherDocs })
3316
+ }
1970
3317
  return out
1971
3318
  }
1972
3319
 
3320
+ /**
3321
+ * Import section landing pages that the source models structurally rather than
3322
+ * as a link. In llms.txt a section like the Vercel CLI is a bare `## CLI`
3323
+ * heading followed by 68 `/docs/cli/<cmd>` links — the importer turns the
3324
+ * heading into a category but `/docs/cli` itself is never listed, so its real,
3325
+ * content-bearing overview page is dropped. ReadMe then redirects the
3326
+ * content-less category to its first child (a broken landing page).
3327
+ *
3328
+ * For each category we compute the common URL prefix of its children (the
3329
+ * section's landing URL), HTTP-probe it, and — when it really exists and stays
3330
+ * in scope — prepend it as a real page so nestByUrlHierarchy renders it as the
3331
+ * parent holding the children. Mutates `organized` in place.
3332
+ */
3333
+ async function injectSectionLandingPages(organized, sourceUrl) {
3334
+ const cats = organized?.categories || []
3335
+ if (cats.length === 0) return
3336
+
3337
+ // Dedup set: every page URL already in the tree, by normalized path.
3338
+ const known = new Set()
3339
+ for (const c of cats) {
3340
+ for (const p of collectUrlPagesDeep(c.pages || [])) known.add(normalizePath(p.url))
3341
+ }
3342
+
3343
+ // Base path of the import itself — landing candidates must be strictly
3344
+ // deeper than this so we never probe `/docs` (the base) as a "section".
3345
+ const baseDepth = (urlTrieSegs(sourceUrl) || []).length
3346
+ const origin = new URL(sourceUrl).origin
3347
+
3348
+ // Build one landing candidate per eligible category (cheap gate first).
3349
+ const candidates = []
3350
+ for (const cat of cats) {
3351
+ const kids = collectUrlPagesDeep(cat.pages || [])
3352
+ if (kids.length < 2) continue
3353
+
3354
+ // Longest common path prefix across the children — same loop as
3355
+ // clusterByUrlPath's common-depth scan, over urlTrieSegs.
3356
+ const parts = kids.map((p) => urlTrieSegs(p.url)).filter(Boolean)
3357
+ if (parts.length < 2) continue
3358
+ let commonDepth = 0
3359
+ while (commonDepth < parts[0].length) {
3360
+ const seg = parts[0][commonDepth]
3361
+ if (!parts.every((pp) => pp[commonDepth] === seg)) break
3362
+ commonDepth++
3363
+ }
3364
+ const commonSegs = parts[0].slice(0, commonDepth)
3365
+
3366
+ // Must be strictly deeper than the import base, and not already a page.
3367
+ if (commonSegs.length <= baseDepth) continue
3368
+ const landingUrl = `${origin}/${commonSegs.join('/')}`
3369
+ if (known.has(normalizePath(landingUrl))) continue
3370
+
3371
+ candidates.push({ cat, landingUrl })
3372
+ }
3373
+ if (candidates.length === 0) return
3374
+
3375
+ // Probe surviving candidates in parallel. Accept only a real, in-scope page
3376
+ // that doesn't redirect onto an already-known child.
3377
+ const accepted = []
3378
+ await visitAllInParallel(
3379
+ candidates,
3380
+ async ({ cat, landingUrl }) => {
3381
+ try {
3382
+ const res = await fetch(landingUrl, {
3383
+ redirect: 'follow',
3384
+ headers: { 'User-Agent': 'readme-cli-import' },
3385
+ })
3386
+ if (!res.ok) return
3387
+ const candPath = new URL(landingUrl).pathname.toLowerCase()
3388
+ const finalPath = new URL(res.url).pathname.toLowerCase()
3389
+ if (!finalPath.startsWith(candPath)) return
3390
+ if (known.has(normalizePath(res.url))) return
3391
+
3392
+ // Check `<meta http-equiv="refresh">` navigates the browser elsewhere.
3393
+ const html = await res.text()
3394
+ const metaRefresh = parseMetaRefreshTarget(html, res.url)
3395
+ if (metaRefresh && normalizePath(metaRefresh) !== normalizePath(res.url)) return
3396
+ const canonical = parseHtmlCanonical(html, res.url)
3397
+ if (canonical && known.has(normalizePath(canonical))) return
3398
+
3399
+ accepted.push({ cat, landingUrl })
3400
+ } catch {
3401
+ // Network error / unparseable — treat as "no landing page".
3402
+ }
3403
+ },
3404
+ LANDING_PROBE_CAP,
3405
+ )
3406
+
3407
+ for (const { cat, landingUrl } of accepted) {
3408
+ // Don't set x-from-llms: the page isn't in llmsPaths, so the runner fetches
3409
+ // its rendered HTML via the content cascade (there's no .md mirror).
3410
+ cat.pages.unshift({ title: cat.title, url: landingUrl })
3411
+ known.add(normalizePath(landingUrl))
3412
+ }
3413
+ if (accepted.length > 0) {
3414
+ styles.info(`Imported ${styles.bold(String(accepted.length))} section landing page(s).`)
3415
+ }
3416
+ }
3417
+
3418
+ /**
3419
+ * Parse a client-side redirect target from an HTML page. Return pages with
3420
+ * `<meta http-equiv="refresh" content="0;url=/target/">` that navigates
3421
+ * the browser elsewhere.
3422
+ */
3423
+ function parseMetaRefreshTarget(html, baseUrl) {
3424
+ if (!html) return null
3425
+ const tag = /<meta[^>]+http-equiv=["']?refresh["']?[^>]*>/i.exec(html)
3426
+ if (!tag) return null
3427
+ const m = /content=["'][^"']*url=([^"'>\s]+)/i.exec(tag[0])
3428
+ if (!m) return null
3429
+ try {
3430
+ return new URL(decodeEntities(m[1].trim()), baseUrl).toString()
3431
+ } catch {
3432
+ return null
3433
+ }
3434
+ }
3435
+
3436
+ /**
3437
+ * Parse the `<link rel="canonical" href="...">` target from an HTML page,
3438
+ * resolved absolute against `baseUrl`. Returns null when absent or unparseable.
3439
+ */
3440
+ function parseHtmlCanonical(html, baseUrl) {
3441
+ if (!html) return null
3442
+ const tag = /<link[^>]+rel=["']?canonical["']?[^>]*>/i.exec(html)
3443
+ if (!tag) return null
3444
+ const m = /href=["']([^"']+)["']/i.exec(tag[0])
3445
+ if (!m) return null
3446
+ try {
3447
+ return new URL(decodeEntities(m[1].trim()), baseUrl).toString()
3448
+ } catch {
3449
+ return null
3450
+ }
3451
+ }
3452
+
1973
3453
  /**
1974
3454
  * Used by discovery-mode scraping (no llms.txt) to decide whether a nav
1975
3455
  * link is worth importing as a doc page. Filters out cross-origin links,
@@ -1990,6 +3470,20 @@ function isDiscoverableLink(abs, base) {
1990
3470
  return true
1991
3471
  }
1992
3472
 
3473
+ // Leading-segment auth routes: /auth, /login, /sign-in, /logout, /sso, /oauth…
3474
+ // These are login/account chrome (Stoplight's "Sign in" → /auth, etc.), not
3475
+ // docs. NOT filtered at link-discovery time — on some Stoplight sites the
3476
+ // bare landing page only exposes the "Sign in" link, which we still need as a
3477
+ // crawl seed to reach the real sidebar. Pruned from the tree post-crawl.
3478
+ const AUTH_ROUTE_RE = /^\/(?:auth|login|log-?in|sign-?in|sign-?up|signup|logout|log-?out|sign-?out|signout|register|sso|oauth2?)(?:\/|$)/i
3479
+ function isAuthChromeUrl(url) {
3480
+ try {
3481
+ return AUTH_ROUTE_RE.test(new URL(url).pathname)
3482
+ } catch {
3483
+ return false
3484
+ }
3485
+ }
3486
+
1993
3487
  // Zero-width spaces, direction marks, word joiner, BOM — some docs sites
1994
3488
  // inject these into sidebar headings (docs.greenflash.ai prefixes
1995
3489
  // "API Reference" with U+200B), which otherwise defeats downstream
@@ -2029,20 +3523,7 @@ function decodeEntities(s) {
2029
3523
  .replace(/&rdquo;/g, '”')
2030
3524
  }
2031
3525
 
2032
- /**
2033
- * Reduce a URL to a comparable pathname: lowercase host, strip trailing slash
2034
- * and common suffixes (.md, .html) so `/foo/bar.md` and `/foo/bar` match.
2035
- */
2036
- function normalizePath(url) {
2037
- try {
2038
- const u = new URL(url)
2039
- let p = u.pathname.replace(/\/$/, '').toLowerCase()
2040
- p = p.replace(/\.(md|mdx|html?)$/i, '')
2041
- return p
2042
- } catch {
2043
- return String(url).toLowerCase()
2044
- }
2045
- }
3526
+ // normalizePath is imported from ../utils/url-segs.js
2046
3527
 
2047
3528
  /**
2048
3529
  * Given a set of scraped categories + orphan pages that didn't match the nav
@@ -2056,7 +3537,8 @@ async function slotOrphansWithClaude(scraped, orphans, model) {
2056
3537
  categories: scraped.categories,
2057
3538
  orphans,
2058
3539
  })
2059
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3540
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: slotOrphansOutputSchema })
3541
+ const raw = result.assignments
2060
3542
  if (!Array.isArray(raw)) {
2061
3543
  // If the model didn't cooperate, return all orphans unassigned.
2062
3544
  return orphans
@@ -2086,8 +3568,8 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
2086
3568
  const { systemPrompt, userPrompt } = iconizeNavPrompt({
2087
3569
  categories: scraped.categories,
2088
3570
  })
2089
- const icons = await runJsonQuery({ systemPrompt, userPrompt, model })
2090
- const iconArr = Array.isArray(icons) ? icons : []
3571
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: iconizeNavOutputSchema })
3572
+ const iconArr = Array.isArray(result.icons) ? result.icons : []
2091
3573
  return {
2092
3574
  title: siteTitle || null,
2093
3575
  categories: scraped.categories.map((c, i) => ({
@@ -2099,31 +3581,22 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
2099
3581
  }
2100
3582
 
2101
3583
  /**
2102
- * Sections are "usable" when the llms.txt already did the hard grouping work
2103
- * for us — meaningful titles, not too many/few, each populated. When usable we
2104
- * take a fast path that only asks Claude for icons + title polish instead of
2105
- * re-bucketing every page, which is the slow part of a full reorg.
2106
- */
2107
- // Sections named like these are catch-all buckets — even in richly-structured
2108
- // llms.txt files (e.g. Stripe's "Docs" section is where pages go that don't
2109
- // fit into a named product tab), so always drop them rather than promote the
2110
- // grab-bag contents to a top-level sidebar category.
2111
- const GENERIC_SECTION_RE =
2112
- /^(resources?|english|root url|pages?|docs?|documentation|content|available languages.*|site|sitemap|index|home|optional|instructions?(\s|:).*|miscellaneous|misc|other)$/i
2113
-
2114
- /**
2115
- * Return the subset of llms.txt sections that carry real structural signal —
2116
- * drop catch-all buckets ("Docs", "Resources", "Optional"), empty sections,
2117
- * and oversized ones (site-dumps masquerading as sections).
3584
+ * Return the subset of llms.txt sections worth sending to the fast path —
3585
+ * drop only structurally bad ones (empty, or oversized site-dumps). Heading
3586
+ * names are preserved verbatim: a heading like "Docs" or "Documentation"
3587
+ * might be a catch-all on one site (bad) and a real top-level category on
3588
+ * another (fine), and we can't tell cheaply, so we don't try.
2118
3589
  */
2119
3590
  function usableSections(sections) {
2120
3591
  if (!sections) return []
2121
- return sections.filter((s) => s.title && !GENERIC_SECTION_RE.test(s.title.trim()) && s.items && s.items.length > 0 && s.items.length <= 200)
3592
+ return sections.filter((s) => s.title && s.items && s.items.length > 0 && s.items.length <= 200)
2122
3593
  }
2123
3594
 
2124
3595
  function sectionsLookUsable(sections) {
2125
- if (!sections || sections.length > 40) return false
2126
- return usableSections(sections).length >= 3
3596
+ const usable = usableSections(sections)
3597
+ if (usable.length < 3) return false
3598
+ const avg = usable.reduce((n, s) => n + s.items.length, 0) / usable.length
3599
+ return avg >= 5 && avg <= 100
2127
3600
  }
2128
3601
 
2129
3602
  async function organizeWithClaude(parsed, model) {
@@ -2139,16 +3612,17 @@ async function organizeWithClaude(parsed, model) {
2139
3612
  * is O(sections), not O(pages), so this is usually ~5-15s vs. a full reorg.
2140
3613
  */
2141
3614
  async function organizeFromSections(parsed, model) {
2142
- // Drop generic/empty/oversized sections so they don't pollute the sidebar
2143
- // (e.g. Stripe's llms.txt has a "Docs" catch-all and a 0-item "Instructions
2144
- // for Large Language Model Agents" section — neither is structural signal).
3615
+ // Drop empty/oversized sections (e.g. a 0-item "Instructions for Large
3616
+ // Language Model Agents" preamble, or a 500-row site-dump) — heading
3617
+ // names are preserved as-is.
2145
3618
  const sections = usableSections(parsed.sections)
2146
3619
 
2147
3620
  const { systemPrompt, userPrompt } = organizeFromSectionsPrompt({
2148
3621
  siteTitle: parsed.title,
2149
3622
  sections,
2150
3623
  })
2151
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3624
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromSectionsOutputSchema })
3625
+ const raw = result.sections
2152
3626
  if (!Array.isArray(raw)) {
2153
3627
  throw new Error('Fast-path expected a JSON array of {title, icon} entries.')
2154
3628
  }
@@ -2183,18 +3657,22 @@ async function organizeFromScratch(parsed, model) {
2183
3657
  siteTitle: parsed.title,
2184
3658
  items,
2185
3659
  })
2186
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3660
+ const raw = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromScratchOutputSchema })
2187
3661
 
2188
3662
  // Rehydrate pages from the id references Claude returned.
2189
3663
  const expandedCategories = []
2190
3664
  const usedIds = new Set()
3665
+ const usedUrls = new Set()
2191
3666
  for (const cat of raw.categories || []) {
2192
3667
  const pages = []
2193
3668
  for (const id of cat.pageIds || []) {
2194
3669
  const item = items[id]
2195
3670
  if (!item) continue // ignore out-of-range ids
2196
3671
  if (usedIds.has(id)) continue // ignore dupes
3672
+ const normUrl = normalizePath(item.url)
3673
+ if (usedUrls.has(normUrl)) continue // guard against same URL at different input indexes
2197
3674
  usedIds.add(id)
3675
+ usedUrls.add(normUrl)
2198
3676
  pages.push({
2199
3677
  title: item.title,
2200
3678
  url: item.url,
@@ -2224,11 +3702,13 @@ async function organizeFromScratch(parsed, model) {
2224
3702
  }
2225
3703
 
2226
3704
  /**
2227
- * Shared Claude call for "send a prompt, parse JSON back". Logs the prompts so
2228
- * we can see what went in, runs a heartbeat so silent model latency doesn't
2229
- * look like a hang, and strips stray code fences if the model adds them.
3705
+ * Shared Claude call for "send a prompt, get schema-validated JSON back".
3706
+ * Logs the prompts so we can see what went in, and runs a heartbeat so silent
3707
+ * model latency doesn't look like a hang. The SDK enforces `schema` and
3708
+ * returns parsed data on the result message's `structured_output` field, so
3709
+ * there's no text extraction or JSON.parse on this path.
2230
3710
  */
2231
- async function runJsonQuery({ systemPrompt, userPrompt, model }) {
3711
+ async function runJsonQuery({ systemPrompt, userPrompt, model, schema }) {
2232
3712
  console.log()
2233
3713
  console.log(styles.dim('─ system prompt ─'))
2234
3714
  console.log(styles.dim(systemPrompt))
@@ -2242,24 +3722,28 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2242
3722
  console.log()
2243
3723
 
2244
3724
  const heartbeat = setInterval(() => process.stdout.write(styles.dim('.')), 1000)
2245
- let text = ''
3725
+ let structured
2246
3726
  try {
2247
3727
  for await (const message of query({
2248
3728
  prompt: userPrompt,
2249
3729
  options: {
2250
3730
  systemPrompt,
2251
3731
  allowedTools: [],
3732
+ outputFormat: { type: 'json_schema', schema },
2252
3733
  ...(model ? { model } : {}),
2253
3734
  },
2254
3735
  })) {
2255
- if (message.type === 'assistant' && message.message?.content) {
2256
- for (const block of message.message.content) {
2257
- if (block.type === 'text' && block.text) text += block.text
3736
+ if (message.type === 'result') {
3737
+ if (message.subtype === 'error_max_structured_output_retries') {
3738
+ throw new Error(
3739
+ 'Claude could not produce output matching the schema after retries. ' +
3740
+ 'Likely hit the model\'s output limit — try --model sonnet.',
3741
+ )
2258
3742
  }
2259
- } else if (message.type === 'result') {
2260
3743
  if (message.subtype && message.subtype !== 'success') {
2261
3744
  throw new Error(`Claude failed: ${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
2262
3745
  }
3746
+ structured = message.structured_output
2263
3747
  break
2264
3748
  }
2265
3749
  }
@@ -2268,18 +3752,10 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2268
3752
  process.stdout.write('\n')
2269
3753
  }
2270
3754
 
2271
- const stripped = stripCodeFences(text)
2272
-
2273
- try {
2274
- return JSON.parse(stripped)
2275
- } catch (e) {
2276
- throw new Error(
2277
- `Claude returned invalid JSON: ${e.message}\n` +
2278
- `Output length: ${stripped.length} chars. Likely hit the model's output limit — try --model sonnet.\n\n` +
2279
- `First 500 chars:\n${stripped.slice(0, 500)}\n\n` +
2280
- `Last 500 chars:\n${stripped.slice(-500)}`,
2281
- )
3755
+ if (!structured || typeof structured !== 'object') {
3756
+ throw new Error('Claude returned no structured output')
2282
3757
  }
3758
+ return structured
2283
3759
  }
2284
3760
 
2285
3761
  /**
@@ -2294,85 +3770,725 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2294
3770
  *
2295
3771
  * Returns deduped URLs in probe order.
2296
3772
  */
2297
- function buildLlmsCandidates(sourceUrl) {
3773
+ // URL patterns we strip from llms.txt items — they show up alongside real
3774
+ // pages but shouldn't be staged as docs:
3775
+ // - .yaml specs, package.json-ish assets, sitemaps, etc. (machine-readable)
3776
+ // - llms.txt, llms-full.txt, llms-ctx.txt, … (other llms.txt variants
3777
+ // that point back at the same index/dump, not real pages)
3778
+ //
3779
+ // `.json`/`.yaml`/`.yml` spec links are captured up-front by
3780
+ // extractOasSpecUrlsFromParsed (candidate OpenAPI specs we download into oas/
3781
+ // and upload separately) and removed from the parsed tree before this drop
3782
+ // runs, so spec-shaped items never reach here — `.yaml` below is a backstop.
3783
+ const ASSET_EXT_RE = /\.(ya?ml|xml|toml)$/i
3784
+ const LLMS_TXT_RE = /(?:^|\/)llms[^/]*\.txt$/i
3785
+ function isAssetOrMetaUrl(url) {
3786
+ try {
3787
+ const pn = new URL(url).pathname
3788
+ return ASSET_EXT_RE.test(pn) || LLMS_TXT_RE.test(pn)
3789
+ } catch {
3790
+ return false
3791
+ }
3792
+ }
3793
+
3794
+ // A llms.txt item whose URL points at a spec-shaped file (.json/.yaml/.yml).
3795
+ // These are pulled out of the parsed tree before organization (so stub
3796
+ // generation never sees them) and downloaded into the staged `oas/` dir; the
3797
+ // runner validates each as OpenAPI and uploads the valid ones.
3798
+ function isOasSpecUrl(url) {
3799
+ try {
3800
+ return /\.(json|ya?ml)$/i.test(new URL(url).pathname)
3801
+ } catch {
3802
+ return false
3803
+ }
3804
+ }
3805
+
3806
+ // Immediate-child segments of root that signal a docs subtree on a
3807
+ // non-docs hostname. We deliberately keep the set tiny — over-matching
3808
+ // here (e.g. including `api`, `learn`) would over-filter sites whose
3809
+ // "docs" subtree lives at the apex.
3810
+ const DOCS_LIKE_ROOT_SEGMENTS = new Set(['docs', 'doc', 'documentation', 'document'])
3811
+
3812
+ /**
3813
+ * Narrow a merged llms.txt result to a docs subtree when a root-level
3814
+ * llms.txt pulled non-doc URLs (marketing, pricing, blog, etc.) into the
3815
+ * union.
3816
+ *
3817
+ * Sites that publish a root llms.txt alongside per-product subtree files
3818
+ * often mix marketing/pricing/blog links into the root file. The BFS merge
3819
+ * happily unions all of it, and without this pass we'd stage stubs for
3820
+ * those pages too.
3821
+ *
3822
+ * elevenlabs.io is the canonical example: its root `elevenlabs.io/llms.txt`
3823
+ * lists marketing pages alongside docs, and per-product files live at
3824
+ * `elevenlabs.io/docs/<product>/llms.txt`. After the BFS we have docs URLs
3825
+ * + pricing/blog URLs in the same merged result. With this pass we keep
3826
+ * only URLs under `/docs/`.
3827
+ *
3828
+ * Conditions (all must hold), chosen so we only narrow when the risk is
3829
+ * real and there's a clear scope to narrow to:
3830
+ * - Hostname doesn't itself contain "docs" — `docs.example.com` is
3831
+ * already a docs-only host; nothing to narrow.
3832
+ * - A root-level llms.txt was actually probed and hit (valid OR
3833
+ * invalid). Either way it's what dragged in the wider URL space via
3834
+ * the BFS expansion, so it's the trigger we care about.
3835
+ * - Multiple llms.txt files contributed to the merge. A single-file
3836
+ * merge is by definition scoped to that one file's intent.
3837
+ * - The merged URL set has a docs-shaped first-segment child of root
3838
+ * (`/docs/`, `/documentation/`, …). That child is our scope target.
3839
+ *
3840
+ * Mutates `llms.parsed.sections` in place. Returns `{ segment, kept,
3841
+ * dropped }` when narrowing happened, or `null` when conditions weren't
3842
+ * met.
3843
+ */
3844
+ function narrowToDocsSubtreeIfNeeded(llms, sourceUrl, hits) {
3845
+ if (!llms || llms.sourceFiles.length < 2) return null
3846
+ if (sourceUrl.hostname.toLowerCase().includes('docs')) return null
3847
+ const rootHit = hits.some((h) => h.path === '/' || h.path === '')
3848
+ if (!rootHit) return null
3849
+
3850
+ // Identify docs-shaped first segments of root that actually appear in
3851
+ // the merged URLs. Walking just the first path segment matches the
3852
+ // user's spec ("we only look at immediate children in this case").
3853
+ const firstSegs = new Set()
3854
+ for (const section of llms.parsed.sections) {
3855
+ for (const item of section.items) {
3856
+ try {
3857
+ const seg = new URL(item.url).pathname.split('/').filter(Boolean)[0]
3858
+ if (seg) firstSegs.add(seg.toLowerCase())
3859
+ } catch {
3860
+ // ignore unparseable URLs
3861
+ }
3862
+ }
3863
+ }
3864
+ let docsSegment = null
3865
+ for (const seg of firstSegs) {
3866
+ if (DOCS_LIKE_ROOT_SEGMENTS.has(seg)) {
3867
+ docsSegment = seg
3868
+ break
3869
+ }
3870
+ }
3871
+ if (!docsSegment) return null
3872
+
3873
+ const prefix = '/' + docsSegment
3874
+ let kept = 0
3875
+ let dropped = 0
3876
+ const keptSections = []
3877
+ for (const section of llms.parsed.sections) {
3878
+ const items = []
3879
+ for (const item of section.items) {
3880
+ try {
3881
+ const pn = new URL(item.url).pathname.toLowerCase()
3882
+ if (pn === prefix || pn.startsWith(prefix + '/')) {
3883
+ items.push(item)
3884
+ kept++
3885
+ } else {
3886
+ dropped++
3887
+ }
3888
+ } catch {
3889
+ dropped++
3890
+ }
3891
+ }
3892
+ if (items.length > 0) {
3893
+ section.items = items
3894
+ keptSections.push(section)
3895
+ }
3896
+ }
3897
+ llms.parsed.sections = keptSections
3898
+ return { segment: docsSegment, kept, dropped }
3899
+ }
3900
+
3901
+ /**
3902
+ * Pull spec-shaped items (.json/.yaml/.yml) out of a parsed llms.txt structure
3903
+ * IN PLACE, returning the removed items as `[{ url, text, source }]`. Sections
3904
+ * emptied by the removal are pruned too — same shape as
3905
+ * dropAssetItemsFromParsed. Run BEFORE dropAssetItemsFromParsed / knownUrls /
3906
+ * organize so the captured specs never enter the stub tree (skeleton has zero
3907
+ * visibility of OAS files). The caller downloads the returned URLs into the
3908
+ * staged `oas/` dir.
3909
+ */
3910
+ function extractOasSpecUrlsFromParsed(parsed) {
3911
+ const captured = []
3912
+ const keptSections = []
3913
+ for (const section of parsed.sections) {
3914
+ const keptItems = []
3915
+ for (const item of section.items) {
3916
+ if (isOasSpecUrl(item.url)) {
3917
+ captured.push({ url: item.url, text: item.text || null, source: 'llms' })
3918
+ continue
3919
+ }
3920
+ keptItems.push(item)
3921
+ }
3922
+ if (keptItems.length > 0) {
3923
+ section.items = keptItems
3924
+ keptSections.push(section)
3925
+ }
3926
+ }
3927
+ parsed.sections = keptSections
3928
+ return captured
3929
+ }
3930
+
3931
+ /**
3932
+ * Drop asset/meta items in place from a parsed llms.txt structure. Sections
3933
+ * that end up empty after the drop are removed too. Returns the count of
3934
+ * items dropped.
3935
+ */
3936
+ function dropAssetItemsFromParsed(parsed) {
3937
+ let dropped = 0
3938
+ const keptSections = []
3939
+ for (const section of parsed.sections) {
3940
+ const keptItems = []
3941
+ for (const item of section.items) {
3942
+ if (isAssetOrMetaUrl(item.url)) {
3943
+ dropped++
3944
+ continue
3945
+ }
3946
+ keptItems.push(item)
3947
+ }
3948
+ if (keptItems.length > 0) {
3949
+ section.items = keptItems
3950
+ keptSections.push(section)
3951
+ }
3952
+ }
3953
+ parsed.sections = keptSections
3954
+ return dropped
3955
+ }
3956
+
3957
+
3958
+ /**
3959
+ * Build the ordered list of well-known docs locations to probe. For each route
3960
+ * we emit a subdomain candidate (stripping a leading `www.` first) and, unless
3961
+ * the route is subdomain-only, a path candidate. Base URLs end in `/` so
3962
+ * `${url.href}llms.txt` is well-formed.
3963
+ */
3964
+ function buildWellKnownDocRoutes(sourceUrl) {
2298
3965
  const out = []
2299
3966
  const seen = new Set()
2300
- const add = (url) => {
2301
- if (!seen.has(url)) {
2302
- seen.add(url)
2303
- out.push(url)
2304
- }
3967
+ const add = (kind, href) => {
3968
+ if (seen.has(href)) return
3969
+ seen.add(href)
3970
+ out.push({ kind, url: new URL(href) })
2305
3971
  }
2306
3972
 
2307
- const origin = sourceUrl.origin
2308
- const segs = sourceUrl.pathname.split('/').filter(Boolean)
2309
- for (let i = segs.length; i >= 0; i--) {
2310
- const prefix = segs.slice(0, i).join('/')
2311
- add(`${origin}${prefix ? '/' + prefix : ''}/llms.txt`)
3973
+ const apexHost = sourceUrl.hostname.replace(/^www\./i, '')
3974
+ for (const route of WELL_KNOWN_DOC_ROUTES) {
3975
+ const subHost = `${route}.${apexHost}`
3976
+ if (subHost !== sourceUrl.hostname) add('subdomain', `${sourceUrl.protocol}//${subHost}/`)
3977
+ if (!SUBDOMAIN_ONLY_DOC_ROUTES.has(route)) add('path', `${sourceUrl.origin}/${route}/`)
2312
3978
  }
2313
3979
  return out
2314
3980
  }
2315
3981
 
3982
+ // Content gate for spec candidates — many sites 200 with an HTML shell for
3983
+ // any path, so a body only counts as a spec if it parses as JSON with a
3984
+ // top-level `openapi`/`swagger` version string, or matches the YAML form of
3985
+ // that key. (Pattern mirrors ai-cli-runner/app/lib/oas-sniff.ts — copied,
3986
+ // never imported.)
3987
+ const OAS_TOP_LEVEL_KEY_RE = /^\s*['"]?(openapi|swagger)['"]?\s*:/m
3988
+ function sniffOasContent(text) {
3989
+ const body = String(text)
3990
+ if (/^\s*</.test(body)) return false
3991
+ try {
3992
+ const obj = JSON.parse(body)
3993
+ if (!obj || typeof obj !== 'object') return false
3994
+ return typeof obj.openapi === 'string' || typeof obj.swagger === 'string'
3995
+ } catch {
3996
+ return OAS_TOP_LEVEL_KEY_RE.test(body.slice(0, 64 * 1024))
3997
+ }
3998
+ }
3999
+
4000
+ function buildWellKnownOasProbeUrls(sourceUrl, docsBase, preAdoptionOrigin) {
4001
+ const urls = []
4002
+ const seen = new Set()
4003
+ const add = (href) => {
4004
+ if (seen.has(href)) return
4005
+ seen.add(href)
4006
+ urls.push(href)
4007
+ }
4008
+ const origins = [sourceUrl.origin]
4009
+ if (preAdoptionOrigin && preAdoptionOrigin !== sourceUrl.origin) origins.push(preAdoptionOrigin)
4010
+ for (const origin of origins) {
4011
+ for (const p of WELL_KNOWN_OAS_PATHS) add(`${origin}/${p}`)
4012
+ }
4013
+ if (docsBase && docsBase.kind === 'path') {
4014
+ const base = docsBase.url.href.replace(/\/+$/, '')
4015
+ for (const p of ['openapi.json', 'openapi.yaml', 'swagger.json']) add(`${base}/${p}`)
4016
+ }
4017
+ return urls
4018
+ }
4019
+
2316
4020
  /**
2317
- * Best-effort fetch of a site's /llms.txt. Returns { ok, status, error, parsed }
2318
- * where parsed is { title, sections: [{ title, items: [{ text, url, description }] }] }.
4021
+ * Probe well-known spec paths on the source origins. A hit is an OK response
4022
+ * whose body sniffs as a spec and whose final URL stayed on a probed origin
4023
+ * (redirects to a marketing apex don't count). Returns ALL hits — a site can
4024
+ * serve several specs — with the fetched bytes attached so downloadOasSpecs
4025
+ * never re-downloads them.
2319
4026
  */
2320
- async function fetchLlmsTxt(llmsUrl) {
4027
+ async function probeWellKnownOasPaths(sourceUrl, docsBase, preAdoptionOrigin) {
4028
+ const probeUrls = buildWellKnownOasProbeUrls(sourceUrl, docsBase, preAdoptionOrigin)
4029
+ const allowedOrigins = new Set([sourceUrl.origin])
4030
+ if (preAdoptionOrigin) allowedOrigins.add(preAdoptionOrigin)
4031
+ const hits = await Promise.all(
4032
+ probeUrls.map(async (url) => {
4033
+ try {
4034
+ const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4035
+ if (!res.ok) return null
4036
+ let finalOrigin
4037
+ try {
4038
+ finalOrigin = new URL(res.url || url).origin
4039
+ } catch {
4040
+ return null
4041
+ }
4042
+ if (!allowedOrigins.has(finalOrigin)) return null
4043
+ const body = Buffer.from(await res.arrayBuffer())
4044
+ if (!sniffOasContent(body.toString('utf8'))) return null
4045
+ return { url, text: null, source: 'probe', body }
4046
+ } catch {
4047
+ return null
4048
+ }
4049
+ }),
4050
+ )
4051
+ return hits.filter(Boolean)
4052
+ }
4053
+
4054
+ /**
4055
+ * Follow redirects on the source URL itself and return the final URL when the
4056
+ * site has moved (different origin or path), or null to keep the original.
4057
+ * Trailing-slash and query/hash-only redirects don't count as a move.
4058
+ */
4059
+ async function resolveRedirectedSourceUrl(sourceUrl) {
2321
4060
  try {
2322
- const res = await fetch(llmsUrl, {
2323
- redirect: 'follow',
2324
- headers: { 'User-Agent': 'readme-cli-import' },
2325
- })
2326
- if (!res.ok) return { ok: false, status: res.status }
2327
- const text = await res.text()
2328
- return { ok: true, status: res.status, parsed: parseLlmsTxt(text) }
2329
- } catch (e) {
2330
- return { ok: false, error: e.message }
4061
+ const res = await fetch(sourceUrl.href, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4062
+ if (!res.ok || !res.url) return null
4063
+ const final = new URL(res.url)
4064
+ final.search = ''
4065
+ final.hash = ''
4066
+ const norm = (u) => u.origin + u.pathname.replace(/\/+$/, '')
4067
+ if (norm(final) === norm(sourceUrl)) return null
4068
+ return final
4069
+ } catch {
4070
+ return null
2331
4071
  }
2332
4072
  }
2333
4073
 
2334
4074
  /**
2335
- * Parse the llms.txt format. `##` headings become sections;
2336
- * `- [text](url): description` bullets become items. Items before any `##`
2337
- * land in an implicit "Resources" section.
4075
+ * For a bare homepage, probe well-known docs routes and return the best base to
4076
+ * adopt, or null to leave the source untouched. A route serving a usable
4077
+ * llms.txt always beats one that merely resolves. Returns `{ url, kind, hasLlms }`.
2338
4078
  */
2339
- function parseLlmsTxt(body) {
2340
- const lines = body.split(/\r?\n/)
2341
- let title = null
2342
- const sections = []
2343
- let current = null
2344
-
2345
- const itemRe = /^\s*-\s*\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)(?:\s*[:—–-]\s*(.+))?/
4079
+ async function resolveDocsBaseUrl(sourceUrl) {
4080
+ if (sourceUrl.pathname && sourceUrl.pathname !== '/') return null
4081
+ if (WELL_KNOWN_DOC_ROUTES.includes(sourceUrl.hostname.split('.')[0].toLowerCase())) return null
4082
+
4083
+ const candidates = buildWellKnownDocRoutes(sourceUrl)
4084
+ if (candidates.length === 0) return null
4085
+
4086
+ styles.info(`Probing ${styles.bold(String(candidates.length))} well-known docs routes from ${styles.bold(sourceUrl.toString())}...`)
4087
+
4088
+ const llmsHits = await Promise.all(candidates.map(async (c) => ({ c, res: await fetchLlmsTxt(`${c.url.href}llms.txt`) })))
4089
+ for (const { c, res } of llmsHits) {
4090
+ const url = `${c.url.href}llms.txt`
4091
+ if (res.ok && res.usable && (res.stats?.linkItems ?? 0) > 0) {
4092
+ if (inCandidateScope(c, res.finalUrl)) {
4093
+ styles.info(styles.dim(` ${url} → valid llms.txt (${res.stats.linkItems} links)`))
4094
+ return { url: c.url, kind: c.kind, hasLlms: true }
4095
+ }
4096
+ styles.info(styles.dim(` ${url} → llms.txt redirects to ${new URL(res.finalUrl).host} (alias) — skipping`))
4097
+ } else if (res.ok) {
4098
+ styles.info(styles.dim(` ${url} → no usable llms.txt`))
4099
+ } else {
4100
+ styles.info(styles.dim(` ${url} → ${res.status ? `HTTP ${res.status}` : res.error || 'no llms.txt'}`))
4101
+ }
4102
+ }
2346
4103
 
2347
- for (const line of lines) {
2348
- const h1 = line.match(/^#\s+(.+)$/)
2349
- if (h1 && !title) {
2350
- title = h1[1].trim()
4104
+ const soft404Title = candidates.some((c) => c.kind === 'path') ? await fetchSoft404Title(sourceUrl.origin) : null
4105
+ const existence = await Promise.all(
4106
+ llmsHits.map(async ({ c, res }) => {
4107
+ if (res.error && c.kind === 'subdomain') return { c, exists: false }
4108
+ return { c, ...(await docsRouteResolves(c, soft404Title)) }
4109
+ }),
4110
+ )
4111
+ for (const { c, exists, soft404 } of existence) {
4112
+ if (soft404) {
4113
+ styles.info(styles.dim(` ${c.url.href} → indistinguishable from a nonexistent path (SPA catch-all) — skipping`))
2351
4114
  continue
2352
4115
  }
4116
+ if (exists) {
4117
+ styles.info(styles.dim(` ${c.url.href} → resolves (no llms.txt)`))
4118
+ return { url: c.url, kind: c.kind, hasLlms: false }
4119
+ }
4120
+ }
2353
4121
 
2354
- const h2 = line.match(/^##\s+(.+)$/)
2355
- if (h2) {
2356
- current = { title: h2[1].trim(), items: [] }
2357
- sections.push(current)
2358
- continue
4122
+ styles.info(styles.dim(` no well-known docs route matched — keeping ${sourceUrl.toString()}`))
4123
+ return null
4124
+ }
4125
+
4126
+ /**
4127
+ * Is `finalUrl` (where a fetch landed after following redirects) still within
4128
+ * the candidate's scope? Subdomain candidates must keep their host; path
4129
+ * candidates must keep their path prefix. A cross-scope redirect means the
4130
+ * route is just an alias to somewhere else (usually the marketing apex).
4131
+ */
4132
+ function inCandidateScope(candidate, finalUrl) {
4133
+ let final
4134
+ try {
4135
+ final = new URL(finalUrl || candidate.url.href)
4136
+ } catch {
4137
+ return false
4138
+ }
4139
+ if (candidate.kind === 'subdomain') return final.hostname === candidate.url.hostname
4140
+ const strip = (p) => p.toLowerCase().replace(/\/+$/, '')
4141
+ const finalPath = strip(final.pathname)
4142
+ const candidatePath = strip(candidate.url.pathname)
4143
+ return finalPath === candidatePath || finalPath.startsWith(`${candidatePath}/`)
4144
+ }
4145
+
4146
+ function extractHtmlTitle(html) {
4147
+ const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
4148
+ if (!match) return null
4149
+ const title = match[1].replace(/\s+/g, ' ').trim().toLowerCase()
4150
+ return title || null
4151
+ }
4152
+
4153
+ /**
4154
+ * Fetch a guaranteed-nonexistent path and capture its page title. On SPA
4155
+ * catch-all sites every unknown path returns 200 with the same shell page;
4156
+ * that title becomes the soft-404 baseline. Returns null when the site 404s
4157
+ * properly (no baseline needed) or on any fetch failure.
4158
+ */
4159
+ async function fetchSoft404Title(origin) {
4160
+ const probeUrl = `${origin}/readme-cli-nonexistent-${Math.random().toString(36).slice(2)}`
4161
+ try {
4162
+ const res = await fetch(probeUrl, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4163
+ if (!res.ok) return null
4164
+ return extractHtmlTitle(await res.text())
4165
+ } catch {
4166
+ return null
4167
+ }
4168
+ }
4169
+
4170
+ /**
4171
+ * Does a well-known docs route exist and stay in scope? Follows redirects and
4172
+ * rejects anything that lands outside the candidate (a `docs.` subdomain that
4173
+ * bounces to marketing, a `/docs/` that 302s home). When a soft-404 baseline
4174
+ * title is known, a path candidate serving that same title is a catch-all
4175
+ * response, not real docs. DNS failures → `{ exists: false }`.
4176
+ */
4177
+ async function docsRouteResolves(candidate, soft404Title) {
4178
+ try {
4179
+ const res = await fetch(candidate.url.href, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4180
+ if (!res.ok || !inCandidateScope(candidate, res.url)) return { exists: false }
4181
+ if (candidate.kind === 'path' && soft404Title) {
4182
+ const title = extractHtmlTitle(await res.text())
4183
+ if (title === soft404Title) return { exists: false, soft404: true }
4184
+ }
4185
+ return { exists: true }
4186
+ } catch {
4187
+ return { exists: false }
4188
+ }
4189
+ }
4190
+
4191
+ // Hard cap on total probes during BFS discovery — protects against
4192
+ // pathological sites where every URL returns a "200 + a few link rows"
4193
+ // response and the frontier would otherwise blow up.
4194
+ const LLMS_PROBE_CAP = 30
4195
+ // Per-node cap on child segments to expand. Root-level llms.txt files can
4196
+ // list hundreds of URLs; without this a single root hit could schedule
4197
+ // dozens of unrelated probes (`/blog`, `/pricing`, …).
4198
+ const LLMS_CHILDREN_PER_NODE = 12
4199
+ // Budget for the explicit-link phase of discovery (phase 2 in discoverLlmsTxt).
4200
+ // Separate from LLMS_PROBE_CAP so following authored `llms.txt` links — a
4201
+ // strong, deliberate signal, e.g. docs.snowflake.com's root index listing ~40
4202
+ // per-section files — isn't starved by the speculative slug-walk's budget.
4203
+ const EXPLICIT_LLMS_PROBE_CAP = 200
4204
+
4205
+ // Matches ONLY the canonical index filename, not sibling dumps like
4206
+ // `llms-full.txt` (concatenated page content, not a link index) — those would
4207
+ // add no link rows and just waste a fetch.
4208
+ const LLMS_INDEX_RE = /\/llms\.txt$/i
4209
+
4210
+ /**
4211
+ * Convert a path like `/docs/quickstart` into its llms.txt URL.
4212
+ */
4213
+ function pathToLlmsUrl(origin, path) {
4214
+ if (!path || path === '/') return `${origin}/llms.txt`
4215
+ return `${origin}${path}/llms.txt`
4216
+ }
4217
+
4218
+ function llmsParseOptionsForUrl(llmsUrl) {
4219
+ return { rootRelativeResolution: llmsPathFromUrl(llmsUrl) === '/' ? 'origin' : 'llms-dir' }
4220
+ }
4221
+
4222
+ /**
4223
+ * Derive the path a fetched llms.txt "lives at" (its pathname minus the
4224
+ * trailing `/llms.txt`), so explicit-link hits carry the same `path` shape
4225
+ * the slug-walk hits do — mergeValidHits sorts by it (deepest wins) and
4226
+ * narrowToDocsSubtreeIfNeeded reads it.
4227
+ */
4228
+ function llmsPathFromUrl(llmsUrl) {
4229
+ const pn = new URL(llmsUrl).pathname.replace(/\/llms\.txt$/i, '')
4230
+ return pn || '/'
4231
+ }
4232
+
4233
+ /**
4234
+ * Pull explicit child-index links out of a parsed llms.txt — item rows whose
4235
+ * URL literally points at another `llms.txt` (e.g. Snowflake's root file
4236
+ * listing `…/data-integration/llms.txt`). Restricted to the same origin as the
4237
+ * source: cross-origin llms.txt would drag in unrelated third-party docs, and
4238
+ * the slug-walk's path math assumes one origin. Returns deduped URL strings.
4239
+ */
4240
+ function extractNestedLlmsUrls(parsed, originScope) {
4241
+ const out = new Set()
4242
+ for (const section of parsed.sections) {
4243
+ for (const item of section.items) {
4244
+ try {
4245
+ const u = new URL(item.url)
4246
+ if (u.origin === originScope && LLMS_INDEX_RE.test(u.pathname)) out.add(u.toString())
4247
+ } catch {
4248
+ // ignore unparseable URLs
4249
+ }
2359
4250
  }
4251
+ }
4252
+ return [...out]
4253
+ }
4254
+
4255
+ /**
4256
+ * Given a current path + a parsed llms.txt, return the next paths to probe:
4257
+ * the parent directory (UP) and distinct first-segment-children extracted
4258
+ * from the URL list (DOWN). Children are scoped to URLs whose pathname
4259
+ * actually starts with the current path's prefix — out-of-scope URLs (e.g.
4260
+ * a `/docs/llms.txt` listing `/blog/foo`) are ignored.
4261
+ */
4262
+ function expandLlmsFrontier(currentPath, parsed) {
4263
+ const next = []
4264
+ const prefix = currentPath === '/' || currentPath === '' ? '/' : currentPath + '/'
2360
4265
 
2361
- const item = line.match(itemRe)
2362
- if (item) {
2363
- if (!current) {
2364
- current = { title: 'Resources', items: [] }
2365
- sections.push(current)
4266
+ if (currentPath && currentPath !== '/') {
4267
+ const parent = currentPath.replace(/\/[^/]+$/, '') || '/'
4268
+ next.push(parent)
4269
+ }
4270
+
4271
+ const childSegs = new Set()
4272
+ for (const section of parsed.sections) {
4273
+ for (const item of section.items) {
4274
+ try {
4275
+ const u = new URL(item.url)
4276
+ const pn = u.pathname.toLowerCase()
4277
+ if (!pn.startsWith(prefix.toLowerCase())) continue
4278
+ const rest = pn.slice(prefix.length).split('/').filter(Boolean)
4279
+ if (rest.length > 0) childSegs.add(rest[0])
4280
+ } catch {
4281
+ // ignore unparseable URLs
2366
4282
  }
2367
- current.items.push({
2368
- text: item[1].trim(),
2369
- url: item[2].replace(/[.,;]+$/, ''),
2370
- description: item[3] ? item[3].trim() : null,
2371
- })
4283
+ if (childSegs.size >= LLMS_CHILDREN_PER_NODE) break
4284
+ }
4285
+ if (childSegs.size >= LLMS_CHILDREN_PER_NODE) break
4286
+ }
4287
+ for (const seg of childSegs) {
4288
+ next.push(currentPath === '/' || !currentPath ? `/${seg}` : `${currentPath}/${seg}`)
4289
+ }
4290
+ return next
4291
+ }
4292
+
4293
+ /**
4294
+ * BFS-discover all llms.txt files reachable from sourceUrl by walking UP
4295
+ * the path and DOWN into child segments. Invalid hits (file exists but
4296
+ * fails the usability ratio) are kept as "stepping stones" — they still
4297
+ * expand the frontier so we can find their valid siblings/children — but
4298
+ * don't contribute content to the merged result. Returns:
4299
+ * { hits, misses, skipped }
4300
+ * where `hits` includes both valid (usable: true) and invalid hits.
4301
+ */
4302
+ async function discoverLlmsTxt(sourceUrl) {
4303
+ const tried = new Set()
4304
+ const hits = []
4305
+ const misses = []
4306
+ const skipped = []
4307
+
4308
+ const startSegs = sourceUrl.pathname.split('/').filter(Boolean)
4309
+ const initial = []
4310
+ for (let i = startSegs.length; i >= 0; i--) {
4311
+ initial.push('/' + startSegs.slice(0, i).join('/'))
4312
+ }
4313
+
4314
+ let frontier = initial
4315
+ while (frontier.length > 0 && tried.size < LLMS_PROBE_CAP) {
4316
+ const ring = []
4317
+ for (const p of frontier) {
4318
+ const norm = p === '' ? '/' : p
4319
+ if (tried.has(norm)) continue
4320
+ tried.add(norm)
4321
+ ring.push(norm)
4322
+ if (tried.size >= LLMS_PROBE_CAP) break
4323
+ }
4324
+ if (ring.length === 0) break
4325
+
4326
+ const results = await Promise.all(
4327
+ ring.map(async (path) => {
4328
+ const llmsUrl = pathToLlmsUrl(sourceUrl.origin, path)
4329
+ const res = await fetchLlmsTxt(llmsUrl, llmsParseOptionsForUrl(llmsUrl))
4330
+ return { path, llmsUrl, res }
4331
+ }),
4332
+ )
4333
+
4334
+ const next = []
4335
+ for (const { path, llmsUrl, res } of results) {
4336
+ if (!res.ok) {
4337
+ misses.push({ url: llmsUrl, status: res.status, error: res.error })
4338
+ continue
4339
+ }
4340
+ // Treat as a "hit" only if there's at least one link row — that's the
4341
+ // minimum signal that this is an llms.txt-shaped file (and not just a
4342
+ // 200 response serving the site's HTML or a stray text file). Hits
4343
+ // expand the frontier regardless of whether they pass usability.
4344
+ const linkItems = res.stats?.linkItems ?? 0
4345
+ if (linkItems === 0) {
4346
+ misses.push({ url: llmsUrl, status: 'no link items' })
4347
+ continue
4348
+ }
4349
+ hits.push({ ...res, llmsUrl, path })
4350
+ if (!res.usable) skipped.push({ url: llmsUrl, reason: res.reason })
4351
+ for (const np of expandLlmsFrontier(path, res.parsed)) {
4352
+ if (!tried.has(np)) next.push(np)
4353
+ }
4354
+ }
4355
+ frontier = next
4356
+ }
4357
+
4358
+ // Phase 2 — follow EXPLICIT llms.txt links authored inside the files phase 1
4359
+ // discovered, recursively. No slug-guessing here: we only fetch URLs that
4360
+ // literally point at an llms.txt (a root index listing per-section files,
4361
+ // like docs.snowflake.com). Each new file's own explicit links are followed
4362
+ // in turn until none remain. Dedupes against every URL phase 1 touched
4363
+ // (hits + misses) and against itself; phase 1's `tried` set is keyed by path,
4364
+ // so we track full llms.txt URLs separately here and leave phase 1 untouched.
4365
+ const triedUrls = new Set([...hits.map((h) => h.llmsUrl), ...misses.map((m) => m.url)])
4366
+ let explicitFrontier = []
4367
+ for (const hit of hits) {
4368
+ for (const u of extractNestedLlmsUrls(hit.parsed, sourceUrl.origin)) {
4369
+ if (!triedUrls.has(u)) explicitFrontier.push(u)
4370
+ }
4371
+ }
4372
+
4373
+ let explicitProbes = 0
4374
+ while (explicitFrontier.length > 0 && explicitProbes < EXPLICIT_LLMS_PROBE_CAP) {
4375
+ const ring = []
4376
+ for (const u of explicitFrontier) {
4377
+ if (triedUrls.has(u)) continue
4378
+ triedUrls.add(u)
4379
+ ring.push(u)
4380
+ explicitProbes++
4381
+ if (explicitProbes >= EXPLICIT_LLMS_PROBE_CAP) break
4382
+ }
4383
+ if (ring.length === 0) break
4384
+
4385
+ const results = await Promise.all(ring.map(async (llmsUrl) => ({ llmsUrl, res: await fetchLlmsTxt(llmsUrl, llmsParseOptionsForUrl(llmsUrl)) })))
4386
+
4387
+ const next = []
4388
+ for (const { llmsUrl, res } of results) {
4389
+ if (!res.ok) {
4390
+ misses.push({ url: llmsUrl, status: res.status, error: res.error })
4391
+ continue
4392
+ }
4393
+ const linkItems = res.stats?.linkItems ?? 0
4394
+ if (linkItems === 0) {
4395
+ misses.push({ url: llmsUrl, status: 'no link items' })
4396
+ continue
4397
+ }
4398
+ hits.push({ ...res, llmsUrl, path: llmsPathFromUrl(llmsUrl) })
4399
+ if (!res.usable) skipped.push({ url: llmsUrl, reason: res.reason })
4400
+ for (const nested of extractNestedLlmsUrls(res.parsed, sourceUrl.origin)) {
4401
+ if (!triedUrls.has(nested)) next.push(nested)
4402
+ }
4403
+ }
4404
+ explicitFrontier = next
4405
+ }
4406
+
4407
+ return { hits, misses, skipped }
4408
+ }
4409
+
4410
+ /**
4411
+ * Merge content from all VALID hits into one llms-result-shaped object that
4412
+ * downstream code can consume the same as a single-file result. Pages are
4413
+ * deduped by normalized pathname, with the deepest-path hit winning — the
4414
+ * `/docs/conversational-ai/` file's section assignment beats `/docs/`'s for
4415
+ * any URL under that subtree. Returns null when there are no valid hits.
4416
+ */
4417
+ function mergeValidHits(hits) {
4418
+ const valid = hits.filter((h) => h.usable)
4419
+ if (valid.length === 0) return null
4420
+
4421
+ const deepestFirst = [...valid].sort((a, b) => b.path.length - a.path.length)
4422
+ const shallowestFirst = [...valid].sort((a, b) => a.path.length - b.path.length)
4423
+
4424
+ const claimed = new Map()
4425
+ const sections = []
4426
+ for (const hit of deepestFirst) {
4427
+ for (const section of hit.parsed.sections) {
4428
+ const items = []
4429
+ for (const item of section.items) {
4430
+ const key = llmsPageDedupeKey(item.url)
4431
+ const existing = claimed.get(key)
4432
+ if (existing) {
4433
+ if (compareCanonicalUrlPreference(item.url, existing.url) < 0) existing.url = item.url
4434
+ continue
4435
+ }
4436
+ claimed.set(key, item)
4437
+ items.push(item)
4438
+ }
4439
+ if (items.length > 0) sections.push({ title: section.title, items })
2372
4440
  }
2373
4441
  }
2374
4442
 
2375
- return { title, sections }
4443
+ // Prefer the root-most file's H1 title — usually the canonical site name.
4444
+ const title = shallowestFirst.find((h) => h.parsed.title)?.parsed.title || null
4445
+
4446
+ const stats = valid.reduce(
4447
+ (acc, h) => ({
4448
+ conforming: acc.conforming + (h.stats?.conforming || 0),
4449
+ nonConforming: acc.nonConforming + (h.stats?.nonConforming || 0),
4450
+ total: acc.total + (h.stats?.total || 0),
4451
+ linkItems: acc.linkItems + (h.stats?.linkItems || 0),
4452
+ }),
4453
+ { conforming: 0, nonConforming: 0, total: 0, linkItems: 0 },
4454
+ )
4455
+ stats.ratio = stats.total === 0 ? 0 : stats.conforming / stats.total
4456
+
4457
+ return {
4458
+ parsed: { title, sections },
4459
+ usable: true,
4460
+ reason: null,
4461
+ stats,
4462
+ llmsUrl: shallowestFirst[0].llmsUrl,
4463
+ sourceFiles: shallowestFirst.map((h) => h.llmsUrl),
4464
+ }
4465
+ }
4466
+
4467
+ /**
4468
+ * Best-effort fetch of a site's /llms.txt plus a simple structural usability check.
4469
+ */
4470
+ async function fetchLlmsTxt(llmsUrl, options = {}) {
4471
+ try {
4472
+ const res = await fetch(llmsUrl, {
4473
+ redirect: 'follow',
4474
+ headers: { 'User-Agent': 'readme-cli-import' },
4475
+ })
4476
+ if (!res.ok) return { ok: false, status: res.status }
4477
+ const text = await res.text()
4478
+ const finalUrl = res.url || String(llmsUrl)
4479
+ const analysis = analyzeLlmsTxt(text, finalUrl, finalUrl === String(llmsUrl) ? options : llmsParseOptionsForUrl(finalUrl))
4480
+ return {
4481
+ ok: true,
4482
+ status: res.status,
4483
+ finalUrl: res.url,
4484
+ parsed: analysis.parsed,
4485
+ usable: analysis.usable,
4486
+ reason: analysis.reason,
4487
+ stats: analysis.stats,
4488
+ }
4489
+ } catch (e) {
4490
+ return { ok: false, error: e.message }
4491
+ }
2376
4492
  }
2377
4493
 
2378
4494
  // Path segments that mark a docs scope. Used to find the right "base" for
@@ -2526,9 +4642,10 @@ function urlInScope(urlStr, sitemapUrl, scopePrefix) {
2526
4642
  * Convert a flat sitemap URL list into the `{title, url, description}` shape
2527
4643
  * the rest of the pipeline expects for knownUrls. Title is derived from the
2528
4644
  * last non-empty path segment, with file extensions stripped. Index-style
2529
- * URLs that resolve to the scope root itself are dropped.
4645
+ * URLs that resolve to the scope root itself are dropped. Spec-shaped URLs
4646
+ * (.json/.yaml/.yml) go to `oasSink` instead of becoming pages.
2530
4647
  */
2531
- function sitemapUrlsToKnownUrls(urls) {
4648
+ function sitemapUrlsToKnownUrls(urls, oasSink) {
2532
4649
  const out = []
2533
4650
  const seen = new Set()
2534
4651
  for (const url of urls) {
@@ -2541,6 +4658,10 @@ function sitemapUrlsToKnownUrls(urls) {
2541
4658
  const norm = normalizePath(url)
2542
4659
  if (seen.has(norm)) continue
2543
4660
  seen.add(norm)
4661
+ if (/\.(json|ya?ml)$/i.test(pathname)) {
4662
+ if (oasSink) oasSink.push({ url, text: null, source: 'sitemap' })
4663
+ continue
4664
+ }
2544
4665
  const segs = pathname.split('/').filter(Boolean)
2545
4666
  const last = segs[segs.length - 1] || ''
2546
4667
  const slug = last.replace(/\.[a-z0-9]+$/i, '')
@@ -2554,13 +4675,6 @@ function sitemapUrlsToKnownUrls(urls) {
2554
4675
  return out
2555
4676
  }
2556
4677
 
2557
- /**
2558
- * Write the organized hierarchy to disk as git-format markdown stubs — just
2559
- * frontmatter, no body yet. docs/ pages go under docs/<Category>/<slug>.md;
2560
- * reference/recipes/custom_pages/custom_blocks get their own top-level dir
2561
- * without a category subfolder (the git-format schema doesn't nest them).
2562
- * Writes _order.yaml per directory so sidebar order matches input order.
2563
- */
2564
4678
  /**
2565
4679
  * Recursively print the page tree. Sub-pages are indented under their parent
2566
4680
  * with no leading bullet character, to show them as children of the parent.
@@ -2577,34 +4691,112 @@ function printPagesTree(pages, indentLevel) {
2577
4691
  }
2578
4692
  }
2579
4693
 
4694
+ /**
4695
+ * Collapse redundant single-folder layers in a category/page subtree, in place.
4696
+ *
4697
+ * When a container (category or page) has exactly one child and that child is a
4698
+ * pure folder node — no `x-import-url` gitto/readme has an issue with unique slug
4699
+ * generation.
4700
+ * This is a temporary workaround.
4701
+ * TODO: Remove after fixing on the Gitto/ReadMe side.
4702
+ */
4703
+ function collapseRedundantLayers(container) {
4704
+ for (const child of container.pages || []) collapseRedundantLayers(child)
4705
+ while ((container.pages || []).length === 1) {
4706
+ const only = container.pages[0]
4707
+ if (only.url) break // real page (gets x-import), just has children
4708
+ if (!only.pages || only.pages.length === 0) break // nothing to lift up
4709
+ container.pages = only.pages
4710
+ }
4711
+ }
4712
+
4713
+ // Some sites serve regular guides under an /api/docs/* path (e.g. /api/docs/guides/*,
4714
+ // /api/docs/pricing). Treat a URL as real docs when it has both `api` and `docs` segments
4715
+ // and the segment right after `docs` is not itself a reference/api slug.
4716
+ function isDocsUnderApiPath(url) {
4717
+ try {
4718
+ const segs = new URL(url).pathname.split('/').filter(Boolean).map((s) => s.toLowerCase())
4719
+ const docsIdx = segs.findIndex((s) => s === 'docs' || s === 'doc')
4720
+ if (!segs.includes('api') || docsIdx === -1) return false
4721
+ const afterDocs = segs[docsIdx + 1]
4722
+ return !afterDocs || !/^(api|api[-_]?reference|reference)$/.test(afterDocs)
4723
+ } catch {
4724
+ return false
4725
+ }
4726
+ }
4727
+
4728
+ // Prune a page tree, keeping only URL pages whose url passes `predicate` plus
4729
+ // empty-parent nodes that still have surviving descendants. Nesting preserved.
4730
+ function filterUrlPagesTree(pages, predicate) {
4731
+ const out = []
4732
+ for (const p of pages || []) {
4733
+ const keptKids = filterUrlPagesTree(p.pages, predicate)
4734
+ if (p.url) {
4735
+ if (predicate(p.url)) out.push({ ...p, pages: keptKids })
4736
+ } else if (keptKids.length > 0) {
4737
+ out.push({ ...p, pages: keptKids })
4738
+ }
4739
+ }
4740
+ return out
4741
+ }
4742
+
4743
+ /**
4744
+ * Write the organized hierarchy to disk as git-format markdown stubs — just
4745
+ * frontmatter, no body yet. docs/ pages go under docs/<Category>/<slug>.md;
4746
+ * reference/recipes/custom_pages/custom_blocks get their own top-level dir
4747
+ * without a category subfolder (the git-format schema doesn't nest them).
4748
+ * Writes _order.yaml per directory so sidebar order matches input order.
4749
+ */
2580
4750
  function stageOrganized(organized, stagingDir, opts = {}) {
2581
4751
  const pickIcon = makeIconPicker()
2582
4752
  const byDir = new Map()
2583
4753
  const subDirsByTopDir = new Map()
2584
4754
  const counts = { fileCount: 0, skippedApiRef: 0 }
2585
4755
  const skipApiReference = !!opts.skipApiReference
4756
+ const llmsPaths = opts.llmsPaths || new Set()
2586
4757
 
2587
- const eligibleCategories = (organized.categories || []).filter((cat) => {
4758
+ const eligibleCategories = []
4759
+ for (const cat of organized.categories || []) {
2588
4760
  if (skipApiReference && routeCategory(cat.title).topDir === 'reference') {
2589
- counts.skippedApiRef += countPagesDeep(cat.pages || [])
2590
- return false
4761
+ // A reference-routed cluster can mix genuine OAS pages (/api/reference/*)
4762
+ // with real guides served under /api/docs/*. Rescue the docs subtree —
4763
+ // retitled so it routes to docs/ instead of the dropped reference/
4764
+ const rescuedPages = filterUrlPagesTree(cat.pages || [], isDocsUnderApiPath)
4765
+ const totalDeep = collectUrlPagesDeep(cat.pages || []).length
4766
+ const rescuedDeep = collectUrlPagesDeep(rescuedPages).length
4767
+ if (rescuedDeep > 0) {
4768
+ eligibleCategories.push({ ...cat, title: 'API Docs', pages: rescuedPages })
4769
+ }
4770
+ counts.skippedApiRef += totalDeep - rescuedDeep
4771
+ continue
2591
4772
  }
2592
- return true
2593
- })
4773
+ eligibleCategories.push(cat)
4774
+ }
4775
+
4776
+ // TODO: Remove after readme/gitto handles better
4777
+ for (const cat of eligibleCategories) collapseRedundantLayers(cat)
2594
4778
 
2595
4779
  // Slug names must be unique
2596
4780
  const slugFor = ensureUniqueSlugs(eligibleCategories)
2597
4781
 
2598
- const urlWidth = Math.max(...[...slugFor.keys()].map((p) => (p.url || '(group-only)').length))
4782
+ const labelFor = (p) => {
4783
+ if (p.url) return p.url
4784
+ if (p._emptyParent && p._virtualPathSegs) return `(empty) /${p._virtualPathSegs.join('/')}`
4785
+ return '(group-only)'
4786
+ }
4787
+ const urlWidth = Math.max(...[...slugFor.keys()].map((p) => labelFor(p).length))
2599
4788
  for (const [page, slug] of slugFor) {
2600
- console.log(`${(page.url || '(group-only)').padEnd(urlWidth)} → ${slug}`)
4789
+ console.log(`${labelFor(page).padEnd(urlWidth)} → ${slug}`)
2601
4790
  }
2602
4791
 
2603
4792
  /**
2604
4793
  * Write a page (and its descendants) into `dir`. A page with children gets
2605
- * its own subfolder named after its slug; the parent page lives at
2606
- * `<dir>/<slug>.md` while children live at `<dir>/<slug>/<childSlug>.md`.
2607
- * This matches git-format's on-disk convention for nested sidebars.
4794
+ * its own subfolder named after its slug; its content lives at
4795
+ * `<dir>/<slug>/index.md` while children live at `<dir>/<slug>/<childSlug>.md`.
4796
+ * A leaf page (no children) lives at `<dir>/<slug>.md`. This matches
4797
+ * git-format's on-disk convention: gitto backs a folder's sidebar node with
4798
+ * its `index.md`; a sibling `<slug>.md` next to the folder instead leaves the
4799
+ * folder a contentless placeholder parent and the sibling a stray page.
2608
4800
  */
2609
4801
  function writePage(page, dir, topDir, isSubPage = false) {
2610
4802
  const slug = slugFor.get(page)
@@ -2613,46 +4805,119 @@ function stageOrganized(organized, stagingDir, opts = {}) {
2613
4805
  // no backing page on the source site — they're pure sidebar containers.
2614
4806
  // Skip the stub write but still recurse so their children land in the
2615
4807
  // right subdirectory.
2616
- const isGroupOnly = !page.url
4808
+ const isEmptyParent = !!page._emptyParent
4809
+ const isGroupOnly = !page.url && !isEmptyParent
4810
+ const children = page.pages || []
4811
+ const hasChildren = children.length > 0
2617
4812
 
2618
4813
  if (!isGroupOnly) {
2619
- const relFilePath = `${dir}/${slug}.md`
2620
- // Sub-pages don't get icons per design decision.
2621
- const frontmatter = buildFrontmatter(topDir, page, slug, pickIcon, { skipIcon: isSubPage })
2622
- // x-import points at the source URL for this stub. The content-import
2623
- // step reads it to fetch the page body. x-prefixed custom field is the
2624
- // git-format convention for metadata the schema doesn't know about.
2625
- frontmatter['x-import'] = toBrowsableUrl(page.url)
2626
- // hide pages that need import
2627
- frontmatter.hidden = 'true'
4814
+ // Parents-with-children store their content INSIDE the folder as
4815
+ // `index.md`; leaf pages stay as `<slug>.md`.
4816
+ const relFilePath = hasChildren ? `${dir}/${slug}/index.md` : `${dir}/${slug}.md`
4817
+ let frontmatter
4818
+ if (isEmptyParent) {
4819
+ // Synthetic parent page: covers a missing URL segment so descendants
4820
+ // nest at the right depth. No source URL to import. Reuse the same
4821
+ // pickIcon heuristic leaf pages use so the parent gets a topical
4822
+ // icon — the sidebar already renders an expand chevron, so a
4823
+ // hardcoded `fa-folder` would just look like a placeholder.
4824
+ frontmatter = { title: page.title, icon: formatIconClass(pickIcon(slug, page.title)) }
4825
+ } else {
4826
+ // Sub-pages don't get icons per design decision.
4827
+ frontmatter = buildFrontmatter(topDir, page, slug, pickIcon, { skipIcon: isSubPage })
4828
+ // x-import points at the source URL for this stub. The content-import
4829
+ // step reads it to fetch the page body. x-prefixed custom field is the
4830
+ // git-format convention for metadata the schema doesn't know about.
4831
+ frontmatter['x-import'] = toBrowsableUrl(page.url)
4832
+ // Pages sourced from llms.txt serve raw markdown at `<url>.md`; flag them
4833
+ // so the runner knows it can append `.md` when fetching the body.
4834
+ if (llmsPaths.has(normalizePath(page.url))) frontmatter['x-from-llms'] = 'true'
4835
+ // hide pages that need import
4836
+ frontmatter.hidden = 'true'
4837
+ }
2628
4838
 
2629
4839
  const absPath = path.join(stagingDir, relFilePath)
2630
4840
  fs.mkdirSync(path.dirname(absPath), { recursive: true })
2631
4841
  fs.writeFileSync(absPath, matter.stringify('', frontmatter))
2632
4842
  counts.fileCount++
2633
4843
 
4844
+ // The sidebar entry is `slug` for both leaves (`<slug>.md`) and parents
4845
+ // (the `<slug>/` folder) — `index` is never listed, it's implicit.
2634
4846
  if (!byDir.has(dir)) byDir.set(dir, [])
2635
4847
  byDir.get(dir).push(slug)
2636
4848
  }
2637
4849
 
2638
- const children = page.pages || []
2639
- if (children.length > 0) {
4850
+ if (hasChildren) {
2640
4851
  const subDir = `${dir}/${slug}`
2641
4852
  for (const child of children) writePage(child, subDir, topDir, true)
2642
4853
  }
2643
4854
  }
2644
4855
 
4856
+ // gitto derives a page's slug from the folder BASENAME alone (getSlugFromPath),
4857
+ // so two category folders that share a basename — e.g. docs/Fundamentals and
4858
+ // docs/cockroach/Fundamentals — both slug to `fundamentals` and collide. The
4859
+ // deeper one renders as an unnamed, content-less placeholder parent.
4860
+ // Namespace every NESTED category folder by its ancestor path so its basename
4861
+ // (and thus slug) is globally unique, and back it with an `index.md` so it's a
4862
+ // real titled parent. Top-level categories are matched by title, not slug, so
4863
+ // their folders are left untouched.
4864
+ const usedContainerSlugs = new Set(slugFor.values())
4865
+ const segRename = new Map() // original path prefix -> unique folder name
4866
+ const nestedContainers = new Map() // renamed dir (rel to stagingDir) -> display title
4867
+ const uniqueContainerName = (base) => {
4868
+ let name = base || 'section'
4869
+ if (usedContainerSlugs.has(name)) {
4870
+ let n = 2
4871
+ while (usedContainerSlugs.has(`${name}-${n}`)) n++
4872
+ name = `${name}-${n}`
4873
+ }
4874
+ usedContainerSlugs.add(name)
4875
+ return name
4876
+ }
4877
+ const namespaceSubDir = (topDir, subDir) => {
4878
+ const segs = subDir.split('/').filter(Boolean)
4879
+ const out = []
4880
+ for (let i = 0; i < segs.length; i++) {
4881
+ if (i === 0) {
4882
+ out.push(segs[0])
4883
+ continue
4884
+ }
4885
+ const origPrefix = segs.slice(0, i + 1).join('/')
4886
+ let name = segRename.get(origPrefix)
4887
+ if (!name) {
4888
+ name = uniqueContainerName(kebabCase(segs.slice(0, i + 1).join('-')))
4889
+ segRename.set(origPrefix, name)
4890
+ nestedContainers.set(`${topDir}/${[...out, name].join('/')}`, segs[i])
4891
+ }
4892
+ out.push(name)
4893
+ }
4894
+ return out.join('/')
4895
+ }
4896
+
2645
4897
  for (const cat of eligibleCategories) {
2646
4898
  const { topDir, subDir } = routeCategory(cat.title)
2647
- const dir = subDir ? `${topDir}/${subDir}` : topDir
2648
- if (subDir) {
4899
+ const routedSubDir = subDir ? namespaceSubDir(topDir, subDir) : subDir
4900
+ const dir = routedSubDir ? `${topDir}/${routedSubDir}` : topDir
4901
+ if (routedSubDir) {
2649
4902
  if (!subDirsByTopDir.has(topDir)) subDirsByTopDir.set(topDir, [])
2650
- if (!subDirsByTopDir.get(topDir).includes(subDir)) subDirsByTopDir.get(topDir).push(subDir)
4903
+ if (!subDirsByTopDir.get(topDir).includes(routedSubDir)) subDirsByTopDir.get(topDir).push(routedSubDir)
2651
4904
  }
2652
4905
 
2653
4906
  for (const page of cat.pages || []) writePage(page, dir, topDir, false)
2654
4907
  }
2655
4908
 
4909
+ // Back each namespaced nested-category folder with a titled parent page so
4910
+ // gitto renders it named instead of as an empty placeholder. No x-import —
4911
+ // these are pure nav containers, so the body stays empty and format skips them.
4912
+ for (const [relDir, title] of nestedContainers) {
4913
+ const idxPath = path.join(stagingDir, relDir, 'index.md')
4914
+ if (fs.existsSync(idxPath)) continue
4915
+ fs.mkdirSync(path.dirname(idxPath), { recursive: true })
4916
+ const fm = { title, icon: formatIconClass(pickIcon(path.basename(relDir), title)) }
4917
+ fs.writeFileSync(idxPath, matter.stringify('', fm))
4918
+ counts.fileCount++
4919
+ }
4920
+
2656
4921
  // Per-directory _order.yaml preserves input order in the sidebar.
2657
4922
  for (const [dir, slugs] of byDir) {
2658
4923
  const orderPath = path.join(stagingDir, dir, '_order.yaml')
@@ -2679,6 +4944,85 @@ function countPagesDeep(pages) {
2679
4944
  return n
2680
4945
  }
2681
4946
 
4947
+ /**
4948
+ * Post-staging relocation for `--separate-changelog`: move the staged
4949
+ * `docs/Changelog/` category up to a top-level `changelog/` directory.
4950
+ *
4951
+ * Done as a filesystem move *after* staging (rather than routing there
4952
+ * directly) so the staged tree is a valid git-format layout up to this point,
4953
+ * and the one schema-divergent step lives in a single, clearly-named place.
4954
+ * Note `changelog/` is not part of the git-format schema — it won't lint or
4955
+ * sync to ReadMe until git-format adds first-class changelog support.
4956
+ *
4957
+ * Returns the number of changelog pages moved (0 if there were none).
4958
+ */
4959
+ function relocateChangelogDir(stagingDir) {
4960
+ const srcDir = path.join(stagingDir, 'docs', 'Changelog')
4961
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0
4962
+
4963
+ const dstDir = path.join(stagingDir, 'changelog')
4964
+ fs.renameSync(srcDir, dstDir)
4965
+
4966
+ // Collect every real x-import page from the (possibly nested) tree.
4967
+ // Synthetic group-only stubs (no x-import — they exist only to render an
4968
+ // empty parent in the sidebar) are dropped: the flat layout has no concept
4969
+ // of containers, so they'd just be dead frontmatter.
4970
+ const pages = []
4971
+ const walk = (dir, ancestors) => {
4972
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
4973
+ const full = path.join(dir, entry.name)
4974
+ if (entry.isDirectory()) {
4975
+ walk(full, [...ancestors, entry.name])
4976
+ continue
4977
+ }
4978
+ if (!entry.name.endsWith('.md')) continue
4979
+ const parsed = matter(fs.readFileSync(full, 'utf-8'))
4980
+ if (!parsed.data || !parsed.data['x-import']) continue
4981
+ if (entry.name === 'index.md' && ancestors.length > 0) {
4982
+ pages.push({ ancestors: ancestors.slice(0, -1), slug: ancestors.at(-1), content: parsed.content, data: parsed.data })
4983
+ continue
4984
+ }
4985
+ const slug = entry.name.replace(/\.md$/, '')
4986
+ pages.push({ ancestors, slug, content: parsed.content, data: parsed.data })
4987
+ }
4988
+ }
4989
+ walk(dstDir, [])
4990
+
4991
+ // Wipe the nested tree and rewrite it flat. Filename concatenates the
4992
+ // ancestor folder slugs with the leaf slug so siblings share a prefix —
4993
+ // alphabetical order then naturally matches authored reading order, and
4994
+ // names are unique across the (formerly nested) tree.
4995
+ fs.rmSync(dstDir, { recursive: true, force: true })
4996
+ fs.mkdirSync(dstDir, { recursive: true })
4997
+
4998
+ const flatSlugs = []
4999
+ for (const p of pages) {
5000
+ const flatSlug = [...p.ancestors, p.slug].join('-')
5001
+ const frontmatter = { ...p.data }
5002
+ delete frontmatter.icon
5003
+ fs.writeFileSync(path.join(dstDir, `${flatSlug}.md`), matter.stringify(p.content, frontmatter))
5004
+ flatSlugs.push(flatSlug)
5005
+ }
5006
+
5007
+ flatSlugs.sort()
5008
+ fs.writeFileSync(path.join(dstDir, '_order.yaml'), flatSlugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
5009
+
5010
+ // docs/_order.yaml lists the docs category subfolders — drop the moved one
5011
+ // so the docs sidebar doesn't point at a folder that's no longer there.
5012
+ const docsOrderPath = path.join(stagingDir, 'docs', '_order.yaml')
5013
+ if (fs.existsSync(docsOrderPath)) {
5014
+ const entries = yamlRequire().load(fs.readFileSync(docsOrderPath, 'utf-8'))
5015
+ const filtered = Array.isArray(entries) ? entries.filter((e) => e !== 'Changelog') : []
5016
+ if (filtered.length > 0) {
5017
+ fs.writeFileSync(docsOrderPath, filtered.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
5018
+ } else {
5019
+ fs.rmSync(docsOrderPath)
5020
+ }
5021
+ }
5022
+
5023
+ return pages.length
5024
+ }
5025
+
2682
5026
  /**
2683
5027
  * Map a category title to the git-format top-level directory + optional
2684
5028
  * category subdir. docs/ is the only top dir that takes a subfolder.
@@ -2686,6 +5030,9 @@ function countPagesDeep(pages) {
2686
5030
  function routeCategory(title) {
2687
5031
  const t = (title || '').trim()
2688
5032
  if (/^(api[ -]?reference|reference|api|endpoints?)$/i.test(t)) return { topDir: 'reference', subDir: null }
5033
+ // Changelog-ish categories normalize to one docs/Changelog/ folder so the
5034
+ // --separate-changelog relocation has a single, predictable source dir.
5035
+ if (/^(changelog|change[ -]?log|release[ -]?notes?|releases?|what'?s[ -]?new)$/i.test(t)) return { topDir: 'docs', subDir: 'Changelog' }
2689
5036
  if (/^(recipes?|cookbook|tutorials?|how[ -]?tos?)$/i.test(t)) return { topDir: 'recipes', subDir: null }
2690
5037
  if (/^(custom[ -]?pages?|landing( page)?s?)$/i.test(t)) return { topDir: 'custom_pages', subDir: null }
2691
5038
  if (/^(custom[ -]?blocks?|snippets?|reusable( content)?)$/i.test(t)) return { topDir: 'custom_blocks', subDir: null }
@@ -2722,30 +5069,19 @@ function buildFrontmatter(topDir, page, slug, pickIcon, opts = {}) {
2722
5069
  }
2723
5070
 
2724
5071
  /**
2725
- * Extract URL path segments for slug planning. Strips file extensions and
2726
- * leading numeric prefixes (e.g. `01-intro` → `intro`) the same way the
2727
- * legacy deriveSlug did, so the depth-1 result is byte-for-byte compatible.
2728
- */
2729
- function extractUrlPathSegments(url) {
2730
- if (!url) return []
2731
- try {
2732
- return new URL(url).pathname
2733
- .split('/')
2734
- .filter(Boolean)
2735
- .map((s) => s.replace(/\.(md|mdx|html?)$/i, '').replace(/^\d+[-_.]/, ''))
2736
- .filter(Boolean)
2737
- } catch {
2738
- return []
2739
- }
2740
- }
5072
+ // extractUrlPathSegments is imported from ../utils/url-segs.js
2741
5073
 
2742
5074
  /**
2743
5075
  * Compute a globally-unique slug for every page in the tree.
2744
5076
  *
2745
5077
  * Base slug is the last URL segment (kebab-cased). When two or more pages
2746
5078
  * share a base, we prepend the next-up URL segment to every member of the
2747
- * colliding group and recheck
2748
- *
5079
+ * colliding group and recheck. The category title is treated as the
5080
+ * outermost segment, so when URL segments alone aren't enough (e.g. a
5081
+ * group-only `/v1` parent that appears in multiple categories), expansion
5082
+ * can still reach for the category to disambiguate before falling back to
5083
+ * a numeric `-N` suffix.
5084
+ *
2749
5085
  * @example
2750
5086
  * ensureUniqueSlugs([
2751
5087
  * { title: 'Getting Started', pages: [
@@ -2762,15 +5098,47 @@ function extractUrlPathSegments(url) {
2762
5098
  * // <Overview (Guides)> => 'guides-overview', // expanded (collides)
2763
5099
  * // }
2764
5100
  */
2765
- function ensureUniqueSlugs(categories) {
5101
+ export function ensureUniqueSlugs(categories) {
2766
5102
  const entries = []
2767
- const walk = (pages) => {
5103
+ const segmentsFor = (p) => {
5104
+ if (p._virtualPathSegs && p._virtualPathSegs.length > 0) {
5105
+ return p._virtualPathSegs
5106
+ .map((s) => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
5107
+ .filter(Boolean)
5108
+ }
5109
+ return extractUrlPathSegments(p.url)
5110
+ }
5111
+ const normalizedSegmentsFor = (p) => {
5112
+ const semanticSegments = segmentsFor(p).filter((seg) => !isGenericDocsSegment(seg))
5113
+ return semanticSegments.length > 0 ? semanticSegments : ['introduction']
5114
+ }
5115
+ const expansionSegmentsFor = (p, categorySeg) => {
5116
+ const pageSegments = normalizedSegmentsFor(p)
5117
+ if (!categorySeg || isGenericDocsSegment(categorySeg)) return pageSegments
5118
+ return [categorySeg, ...pageSegments]
5119
+ }
5120
+ const walk = (pages, categorySeg) => {
2768
5121
  for (const p of pages || []) {
2769
- entries.push({ page: p, segments: extractUrlPathSegments(p.url), fallback: p.title, depth: 1 })
2770
- if (p.pages) walk(p.pages)
5122
+ const segments = expansionSegmentsFor(p, categorySeg)
5123
+ // Synthetic/empty-parent nodes have no content. Never let one win a bare
5124
+ // single-segment slug (e.g. a placeholder `…/vercel-flags/cli` claiming
5125
+ // `cli`), or it squats the slug a real overview page wants and ReadMe
5126
+ // redirects the content-less parent to its first child. Floor their slug
5127
+ // at 2 trailing segments so they namespace to `vercel-flags-cli` instead.
5128
+ entries.push({ page: p, segments, fallback: p.title, depth: p._emptyParent ? 2 : 1 })
5129
+ if (p.pages) walk(p.pages, categorySeg)
2771
5130
  }
2772
5131
  }
2773
- for (const c of categories || []) walk(c.pages)
5132
+ const genericCategoriesFirst = [...(categories || [])].sort((a, b) => {
5133
+ const aGeneric = isGenericDocsSegment(a?.title || '')
5134
+ const bGeneric = isGenericDocsSegment(b?.title || '')
5135
+ return Number(bGeneric) - Number(aGeneric)
5136
+ })
5137
+ for (const c of genericCategoriesFirst) {
5138
+ const rawTitle = (c?.title || '').trim()
5139
+ const categorySeg = rawTitle ? kebabCase(rawTitle) : ''
5140
+ walk(c.pages, categorySeg)
5141
+ }
2774
5142
 
2775
5143
  const slugFor = (e) => {
2776
5144
  if (e.segments.length === 0) return kebabCase(e.fallback || 'page') || 'page'
@@ -2813,7 +5181,8 @@ function ensureUniqueSlugs(categories) {
2813
5181
  slug = `${base}-${n}`
2814
5182
  styles.error(
2815
5183
  `Slug collision after segment expansion: ${base} — falling back to ${slug} for ${describe(e)}. ` +
2816
- `Pages were deduped by path before organize, so this indicates the organize step produced duplicates.`,
5184
+ `Pages were deduped by path before organize and category title is already part of the slug, ` +
5185
+ `so this indicates the organize step produced duplicates within a single category.`,
2817
5186
  )
2818
5187
  }
2819
5188
  used.add(slug)
@@ -2822,6 +5191,11 @@ function ensureUniqueSlugs(categories) {
2822
5191
  return result
2823
5192
  }
2824
5193
 
5194
+ const GENERIC_DOCS_SEGMENTS = new Set(['doc', 'docs', 'documentation', 'documentations'])
5195
+ function isGenericDocsSegment(segment) {
5196
+ return GENERIC_DOCS_SEGMENTS.has(kebabCase(segment))
5197
+ }
5198
+
2825
5199
  // Values YAML interprets as non-strings need quoting when used as _order entries.
2826
5200
  const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i
2827
5201
  function yamlSafeSlug(slug) {
@@ -2930,6 +5304,37 @@ function makeIconPicker() {
2930
5304
  }
2931
5305
  }
2932
5306
 
5307
+ export const __test__ = {
5308
+ discoverLlmsTxt,
5309
+ mergeValidHits,
5310
+ resolveRedirectedSourceUrl,
5311
+ resolveDocsBaseUrl,
5312
+ inCandidateScope,
5313
+ tryFernNav,
5314
+ sniffOasContent,
5315
+ buildWellKnownOasProbeUrls,
5316
+ probeWellKnownOasPaths,
5317
+ extractOasUrlsFromHtml,
5318
+ extractMintlifyOasCandidates,
5319
+ sitemapUrlsToKnownUrls,
5320
+ extractOasSpecUrlsFromParsed,
5321
+ downloadOasSpecs,
5322
+ injectSectionLandingPages,
5323
+ parseMetaRefreshTarget,
5324
+ parseHtmlCanonical,
5325
+ isDiscoverableLink,
5326
+ isAuthChromeUrl,
5327
+ isDocsUnderApiPath,
5328
+ filterUrlPagesTree,
5329
+ stageOrganized,
5330
+ urlIsApiReference,
5331
+ urlIsChangelog,
5332
+ reclassifyReferencePages,
5333
+ extractChangelogFromSections,
5334
+ decodeEntities,
5335
+ stripTags,
5336
+ }
5337
+
2933
5338
  function formatDuration(ms) {
2934
5339
  const safe = Math.max(0, ms)
2935
5340
  // Show ms under a second so sub-second work doesn't misleadingly read as "0m 0s".