@readme/cli 0.0.27 → 0.0.29

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 (115) hide show
  1. package/package.json +3 -3
  2. package/src/cli.js +1 -1
  3. package/src/commands/import.js +2134 -430
  4. package/src/commands/import.slug-planner.test.js +86 -0
  5. package/src/commands/import.test.js +136 -0
  6. package/src/commands/oas-sync.js +60 -75
  7. package/src/prompts/index.js +101 -13
  8. package/src/utils/llms.js +224 -0
  9. package/src/utils/llms.test.js +257 -0
  10. package/src/utils/url-segs.js +124 -0
  11. package/src/utils/url-segs.test.js +239 -0
  12. package/src/validators/components.js +1 -0
  13. package/src/validators/duplicates.js +6 -4
  14. package/src/validators/frontmatter.js +11 -0
  15. package/src/validators/oas-reference.js +1 -31
  16. package/src/validators/ordering.js +169 -83
  17. package/vendor/TOOLS.md +1 -1
  18. package/src/dev/.next/app-build-manifest.json +0 -20
  19. package/src/dev/.next/build-manifest.json +0 -31
  20. package/src/dev/.next/cache/.rscinfo +0 -1
  21. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  22. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  23. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  24. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  25. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  26. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  27. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  28. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  29. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  30. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  38. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  40. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  42. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  43. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  50. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  69. package/src/dev/.next/package.json +0 -1
  70. package/src/dev/.next/prerender-manifest.json +0 -11
  71. package/src/dev/.next/react-loadable-manifest.json +0 -1
  72. package/src/dev/.next/routes-manifest.json +0 -1
  73. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  74. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  75. package/src/dev/.next/server/app/page.js +0 -349
  76. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  77. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  78. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  79. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  80. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  81. package/src/dev/.next/server/middleware-manifest.json +0 -32
  82. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  83. package/src/dev/.next/server/middleware.js +0 -1113
  84. package/src/dev/.next/server/next-font-manifest.js +0 -1
  85. package/src/dev/.next/server/next-font-manifest.json +0 -1
  86. package/src/dev/.next/server/pages-manifest.json +0 -5
  87. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  88. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  89. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  90. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  91. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  92. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  93. package/src/dev/.next/server/webpack-runtime.js +0 -209
  94. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  95. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  96. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  97. package/src/dev/.next/static/chunks/app/page.js +0 -28
  98. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  99. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  100. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  101. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  102. package/src/dev/.next/static/css/app/layout.css +0 -559
  103. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  104. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  105. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  106. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  107. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  108. package/src/dev/.next/trace +0 -21
  109. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  110. package/src/dev/.next/types/app/layout.ts +0 -84
  111. package/src/dev/.next/types/app/page.ts +0 -84
  112. package/src/dev/.next/types/cache-life.d.ts +0 -141
  113. package/src/dev/.next/types/package.json +0 -1
  114. package/src/dev/.next/types/routes.d.ts +0 -55
  115. 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,38 @@ 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
+
35
66
  /**
36
67
  * Run the importer programmatically. Mirrors the CLI command but throws on
37
68
  * fatal errors instead of calling `process.exit`, and returns a result object
38
69
  * on success.
39
70
  *
40
71
  * @param {object} options
41
- * @param {string} options.source URL to import from, or path to a local OAS spec.
72
+ * @param {string | string[]} options.source One or more URLs to import from, or a single path to a local OAS spec.
73
+ * Multiple URLs are merged into one zip; same-titled categories merge their pages.
42
74
  * @param {string} [options.output] Output zip path. Defaults to `<basename>-readme.zip` in cwd.
43
75
  * @param {string} [options.model] Claude model alias: 'haiku' | 'sonnet' | 'opus'. Defaults to 'sonnet'.
44
76
  * @param {string} [options.firecrawlKey] Firecrawl API key (falls back to FIRECRAWL_API_KEY env var).
45
77
  * @param {boolean} [options.skipApiReference] Drop pages routed to the API Reference dir.
78
+ * @param {boolean} [options.separateChangelog] Move changelog pages into a top-level `changelog/` dir instead of `docs/Changelog/`.
46
79
  * @param {boolean} [options.test] Skip the zip, keep staging, and boot the dev server.
47
80
  * @param {boolean} [options.debug] Dump intermediate pipeline artifacts to a tmp dir.
48
81
  * @returns {Promise<{ source: 'url' | 'oas', outputZip?: string, stagingDir?: string, fileCount: number, duration: number, phases: Array<{ label: string, ms: number }> }>}
49
82
  */
50
83
  export async function importDocs(options) {
84
+ const model = options.model || DEFAULT_MODEL
85
+ options = { ...options, model }
86
+
51
87
  const startedAt = Date.now()
52
88
  const phases = []
53
89
  const timePhase = async (label, fn) => {
@@ -59,40 +95,265 @@ export async function importDocs(options) {
59
95
 
60
96
  const debugSnapshots = options.debug ? {} : null
61
97
 
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
- }
98
+ // Normalize source → array; preserve back-compat with single-string callers.
99
+ const rawSources = Array.isArray(options.source) ? options.source : [options.source]
100
+ if (rawSources.length === 0) throw new Error('At least one --source is required')
66
101
 
67
- let sourceUrl
68
- try {
69
- sourceUrl = new URL(options.source)
70
- } catch {
71
- throw new Error(`Invalid --source URL: ${options.source}`)
102
+ // Dispatch: any non-URL entry triggers the local-OAS path, which is
103
+ // strictly single-source.
104
+ const hasNonUrl = rawSources.some((s) => !/^https?:\/\//i.test(s))
105
+ if (hasNonUrl) {
106
+ if (rawSources.length > 1) {
107
+ throw new Error('OAS imports accept a single --source; URL imports can accept multiple.')
108
+ }
109
+ return runOasImport(rawSources[0], options, startedAt, phases, timePhase)
72
110
  }
73
111
 
74
- const outputZip = path.resolve(options.output || path.join(process.cwd(), `${sourceUrl.hostname}-readme.zip`))
112
+ const sourceUrls = rawSources.map((s) => {
113
+ try {
114
+ return new URL(s)
115
+ } catch {
116
+ throw new Error(`Invalid --source URL: ${s}`)
117
+ }
118
+ })
119
+
120
+ const hostnameJoined = sourceUrls.map((u) => u.hostname).join('-')
121
+ const outputZip = path.resolve(options.output || path.join(process.cwd(), `${hostnameJoined}-readme.zip`))
75
122
 
76
123
  console.log()
77
- styles.info(`Importing from ${styles.bold(sourceUrl.toString())}`)
124
+ if (sourceUrls.length === 1) {
125
+ styles.info(`Importing from ${styles.bold(sourceUrls[0].toString())}`)
126
+ } else {
127
+ styles.info(`Importing from ${styles.bold(String(sourceUrls.length))} sources:`)
128
+ for (const u of sourceUrls) styles.info(styles.dim(` · ${u.toString()}`))
129
+ }
78
130
  if (!options.test) styles.info(`Output: ${styles.bold(outputZip)}`)
79
131
  console.log()
80
132
 
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'})...`)
133
+ // Per-source pipeline: discovery + scrape + organize, in parallel.
134
+ // Each call returns an `organized` object with the standard shape
135
+ // `{ title, categories: [...] }`; we merge them before staging.
136
+ const perSourceOrganized = await Promise.all(
137
+ sourceUrls.map((sourceUrl) => produceOrganizedForSource(sourceUrl, options, timePhase, debugSnapshots)),
138
+ )
87
139
 
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'}`))
140
+ const organized = sourceUrls.length === 1 ? perSourceOrganized[0] : mergeOrganized(perSourceOrganized)
141
+
142
+ // Union the candidate OpenAPI-spec URLs across all sources, deduped by URL
143
+ // (mergeOrganized intentionally drops non-category fields, so collect from
144
+ // the per-source results directly).
145
+ const oasJsonUrls = []
146
+ const seenOasUrls = new Set()
147
+ for (const o of perSourceOrganized) {
148
+ for (const item of o.oasJsonUrls || []) {
149
+ if (seenOasUrls.has(item.url)) continue
150
+ seenOasUrls.add(item.url)
151
+ oasJsonUrls.push(item)
93
152
  }
94
- return { llms: null, llmsUrl: null }
95
- })
153
+ }
154
+
155
+ const llmsPaths = new Set()
156
+ for (const o of perSourceOrganized) {
157
+ for (const p of o.llmsPaths || []) llmsPaths.add(p)
158
+ }
159
+
160
+ if (debugSnapshots) {
161
+ debugSnapshots['05-organized.json'] = organized
162
+ const debugDir = path.join(os.tmpdir(), `readme-import-debug-${hostnameJoined}-${Date.now()}`)
163
+ fs.mkdirSync(debugDir, { recursive: true })
164
+ for (const [name, data] of Object.entries(debugSnapshots)) {
165
+ fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, null, 2))
166
+ }
167
+ styles.info(`${styles.dim(`Debug snapshots → ${debugDir}`)}`)
168
+ }
169
+ console.log()
170
+
171
+ console.log(` ${styles.bold(organized.title || '(untitled)')}`)
172
+ for (const cat of organized.categories || []) {
173
+ console.log()
174
+ const iconLabel = cat.icon ? `${styles.brand(`[${cat.icon}]`)} ` : ''
175
+ console.log(` ${iconLabel}${styles.bold(cat.title)}`)
176
+ printPagesTree(cat.pages || [], 2)
177
+ }
178
+
179
+ const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-import-'))
180
+
181
+ let result
182
+ try {
183
+ styles.info(`Staging frontmatter stubs in ${styles.bold(stagingDir)}...`)
184
+ const stageStart = Date.now()
185
+ const staged = await timePhase('stage stubs', async () =>
186
+ stageOrganized(organized, stagingDir, { skipApiReference: !!options.skipApiReference, llmsPaths }),
187
+ )
188
+
189
+ // Changelog pages stage into docs/Changelog/ (a schema-valid docs
190
+ // category). With --separate-changelog, relocate them afterwards into a
191
+ // top-level changelog/ dir — done as a post-staging move so the staged
192
+ // tree is a valid git-format layout up to this point.
193
+ if (options.separateChangelog) {
194
+ const movedChangelog = relocateChangelogDir(stagingDir)
195
+ if (movedChangelog > 0) {
196
+ styles.info(
197
+ `Moved ${styles.bold(String(movedChangelog))} changelog page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('changelog/')} (--separate-changelog).`,
198
+ )
199
+ }
200
+ }
201
+
202
+ const landingTitle =
203
+ organized.title ||
204
+ (sourceUrls.length === 1 ? sourceUrls[0].hostname : sourceUrls.map((u) => u.hostname).join(' + '))
205
+ ensureDocsLandingPage(stagingDir, landingTitle)
206
+ styles.ok(
207
+ `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))}.`,
208
+ )
209
+ if (staged.skippedApiRef > 0) {
210
+ styles.info(`Skipped ${styles.bold(String(staged.skippedApiRef))} API reference page${staged.skippedApiRef === 1 ? '' : 's'} (--skip-api-reference)`)
211
+ }
212
+
213
+ // Download captured `.json` specs into <stagingDir>/oas/. Raw bytes only —
214
+ // validation + OpenAPI detection + upload all happen in the runner. Staged
215
+ // here (not in docs/ or reference/) so it rides the zip but is invisible to
216
+ // the docs build. Non-fatal: a failed download is logged and skipped.
217
+ if (oasJsonUrls.length > 0) {
218
+ const oasCount = await timePhase('download oas specs', () => downloadOasSpecs(oasJsonUrls, stagingDir))
219
+ if (oasCount > 0) {
220
+ styles.ok(`Downloaded ${styles.bold(String(oasCount))} candidate OpenAPI spec${oasCount === 1 ? '' : 's'} into ${styles.bold('oas/')}.`)
221
+ }
222
+ }
223
+ console.log()
224
+
225
+ if (options.test) {
226
+ styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Staged ${styles.bold(String(staged.fileCount))} files at ${styles.bold(stagingDir)}`)
227
+ console.log()
228
+ styles.info('Starting the dev server for preview...')
229
+ console.log()
230
+ await runDevPreview(stagingDir)
231
+ return { source: 'url', stagingDir, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
232
+ }
233
+
234
+ if (staged.fileCount === 0) {
235
+ styles.warning('Staging directory is empty — skipping zip.')
236
+ return { source: 'url', fileCount: 0, duration: Date.now() - startedAt, phases }
237
+ }
238
+
239
+ styles.info(`Packaging ${styles.bold(String(staged.fileCount))} files into ${styles.bold(outputZip)}...`)
240
+ await timePhase('zip', () => createZip(stagingDir, outputZip))
241
+
242
+ console.log()
243
+ styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Your ReadMe import is ready at ${styles.bold(outputZip)}`)
244
+ console.log(styles.dim(` ⏱ ${phases.map((p) => `${p.label} ${formatDuration(p.ms)}`).join(' · ')}`))
245
+ result = { source: 'url', outputZip, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
246
+ } finally {
247
+ if (!options.test) {
248
+ fs.rmSync(stagingDir, { recursive: true, force: true })
249
+ }
250
+ }
251
+
252
+ return result
253
+ }
254
+
255
+ /**
256
+ * Run the discovery + scrape + organize pipeline for ONE source URL and
257
+ * return its `organized` object (`{ title, categories: [...] }`). Pure
258
+ * per-source work — no staging, no zipping. `importDocs` runs this in
259
+ * parallel across multiple sources and merges the results.
260
+ *
261
+ * `debugSnapshots`, when provided, gets per-source keys suffixed with
262
+ * the hostname so parallel runs don't clobber each other.
263
+ */
264
+ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSnapshots) {
265
+ const redirected = await timePhase('follow source redirects', () => resolveRedirectedSourceUrl(sourceUrl))
266
+ if (redirected) {
267
+ styles.info(`${styles.bold(sourceUrl.toString())} redirects to ${styles.bold(redirected.toString())} — rebasing import onto the final URL.`)
268
+ sourceUrl = redirected
269
+ }
270
+
271
+ // Probe well-known docs routes (docs./developer. subdomains and /docs/,
272
+ // … path prefixes) and adopt the first that resolves as the real base, so everything downstream runs
273
+ const docsBase = await timePhase('probe well-known docs routes', () => resolveDocsBaseUrl(sourceUrl))
274
+ if (docsBase) {
275
+ styles.info(
276
+ `${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)')}.`,
277
+ )
278
+ sourceUrl = docsBase.url
279
+ }
280
+
281
+ // Discover llms.txt files by BFS walking parents AND children. Seed the
282
+ // frontier with the walk-up paths (source → root). Any "hit" (file exists
283
+ // with at least one link row) expands the frontier: parent path + distinct
284
+ // first-child segments extracted from its URL list. Invalid hits (fail the
285
+ // usability ratio) still expand — they're stepping stones — only valid
286
+ // hits contribute content to the merged result. See
287
+ // mydocs.com/section1/section2 → also tries /section1/, /, and every
288
+ // sibling section2 we discover along the way.
289
+ styles.info(`Discovering llms.txt (walking up and down from ${styles.bold(sourceUrl.toString())})...`)
290
+
291
+ const discovery = await timePhase('discover llms.txt', () => discoverLlmsTxt(sourceUrl))
292
+ for (const hit of discovery.hits) {
293
+ if (hit.usable) {
294
+ styles.info(styles.dim(` ${hit.llmsUrl} → valid (${hit.stats.linkItems} links)`))
295
+ } else {
296
+ styles.info(styles.dim(` ${hit.llmsUrl} → invalid: ${hit.reason} (kept as stepping stone)`))
297
+ }
298
+ }
299
+ for (const miss of discovery.misses) {
300
+ styles.info(styles.dim(` ${miss.url} → ${miss.status ? `HTTP ${miss.status}` : miss.error || 'no llms.txt'}`))
301
+ }
302
+ let llms = mergeValidHits(discovery.hits)
303
+ let llmsUrl = llms?.llmsUrl || null
304
+ const skippedLlms = discovery.skipped
305
+
306
+ // Capture `.json` links (candidate OpenAPI specs) BEFORE anything else sees
307
+ // the parsed tree. They're removed in place here, downloaded into the staged
308
+ // `oas/` dir by importDocs, and validated + uploaded by the runner. Stub
309
+ // generation never sees them.
310
+ let oasJsonUrls = []
311
+ if (llms) {
312
+ oasJsonUrls = extractOasJsonUrlsFromParsed(llms.parsed)
313
+ if (oasJsonUrls.length > 0) {
314
+ styles.info(styles.dim(`Captured ${oasJsonUrls.length} .json link${oasJsonUrls.length === 1 ? '' : 's'} as candidate OpenAPI spec${oasJsonUrls.length === 1 ? '' : 's'} → oas/.`))
315
+ }
316
+ }
317
+
318
+ // Drop the remaining asset/meta items (llms-full.txt, .yaml specs, …) from
319
+ // the merged parsed result up-front, so every downstream consumer — knownUrls
320
+ // AND the "use llms.txt sections directly" organize path — sees a clean URL
321
+ // list. `.json` items were already pulled out above.
322
+ if (llms) {
323
+ const dropped = dropAssetItemsFromParsed(llms.parsed)
324
+ if (dropped > 0) {
325
+ styles.info(styles.dim(`Dropped ${dropped} asset/meta URL${dropped === 1 ? '' : 's'} (.yaml/.xml/.toml, llms*.txt) from llms.txt items.`))
326
+ }
327
+ }
328
+
329
+ if (llms) {
330
+ const narrowed = narrowToDocsSubtreeIfNeeded(llms, sourceUrl, discovery.hits)
331
+ if (narrowed) {
332
+ styles.info(
333
+ styles.dim(
334
+ `Narrowed merged result to /${narrowed.segment}/ subtree (kept ${narrowed.kept}, dropped ${narrowed.dropped} out-of-scope page${narrowed.dropped === 1 ? '' : 's'}).`,
335
+ ),
336
+ )
337
+ }
338
+ }
339
+
340
+ // Fall back to scrape if the merged llms.txt covers too few endpoints to
341
+ // be useful as a structural backbone. Replaces the old per-file
342
+ // MIN_LINK_ROWS=10 check (now relaxed in utils/llms.js) with a global
343
+ // post-merge floor — many tiny per-product llms.txt files can still pass
344
+ // as long as their union clears the bar.
345
+ const MIN_MERGED_LINK_COUNT = 10
346
+ if (llms) {
347
+ const mergedLinkCount = llms.parsed.sections.reduce((n, s) => n + s.items.length, 0)
348
+ if (mergedLinkCount < MIN_MERGED_LINK_COUNT) {
349
+ styles.warning(
350
+ `Merged llms.txt has only ${styles.bold(String(mergedLinkCount))} endpoint${mergedLinkCount === 1 ? '' : 's'} (< ${MIN_MERGED_LINK_COUNT}) — falling back to scrape.`,
351
+ )
352
+ skippedLlms.push({ url: llmsUrl, reason: `merged total only ${mergedLinkCount} link${mergedLinkCount === 1 ? '' : 's'}` })
353
+ llms = null
354
+ llmsUrl = null
355
+ }
356
+ }
96
357
  console.log()
97
358
 
98
359
  let sitemapUrl = null
@@ -105,7 +366,7 @@ export async function importDocs(options) {
105
366
  const sitemapCandidates = buildSitemapCandidates(sourceUrl)
106
367
  const scopePrefix = deriveSitemapScope(sourceUrl)
107
368
  styles.info(
108
- `No llms.txt — checking for sitemap.xml (${sitemapCandidates.length} candidate${sitemapCandidates.length === 1 ? '' : 's'})${scopePrefix ? `, scoped to ${styles.bold(scopePrefix)}` : ''}...`,
369
+ `${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
370
  )
110
371
  const sitemapResult = await timePhase('fetch sitemap.xml', async () => {
111
372
  for (const candidate of sitemapCandidates) {
@@ -125,19 +386,77 @@ export async function importDocs(options) {
125
386
  styles.warning(`No llms.txt or sitemap.xml found — falling back to sidebar discovery via scrape.`)
126
387
  }
127
388
  } else {
128
- styles.info(styles.dim(`Using ${llmsUrl}.`))
389
+ const s = llms.stats
390
+ if (llms.sourceFiles.length === 1) {
391
+ styles.info(styles.dim(`Using ${llmsUrl} (${s.conforming}/${s.total} lines conforming, ratio ${s.ratio.toFixed(2)}).`))
392
+ } else {
393
+ styles.info(styles.dim(`Merged ${llms.sourceFiles.length} llms.txt files (root: ${llmsUrl}; aggregate ratio ${s.ratio.toFixed(2)}).`))
394
+ }
395
+ if (llms.parsed.h3Fallback) {
396
+ styles.info(styles.dim(`H2 sections oversized — re-parsed using H3 (###) headings as section boundaries.`))
397
+ }
129
398
  }
130
399
 
400
+ // Pre-extract changelog pages so AI / URL clustering / section-direct paths
401
+ // never see them as candidates for organization. We re-attach them as a
402
+ // dedicated Changelog category once `organized` is finalized below — that
403
+ // guarantees they route to docs/Changelog/ (or to changelog/ with
404
+ // --separate-changelog) regardless of which organize path ran. Done here,
405
+ // before knownUrls is derived, so every downstream consumer operates on the
406
+ // already-changelog-free view.
407
+ let extractedChangelog = []
408
+ if (llms) {
409
+ extractedChangelog = extractChangelogFromSections(llms.parsed.sections)
410
+ } else if (sitemapKnownUrls.length > 0) {
411
+ const { extracted, kept } = partitionChangelogFromKnownUrls(sitemapKnownUrls)
412
+ extractedChangelog = extracted
413
+ sitemapKnownUrls = kept
414
+ }
415
+ if (extractedChangelog.length > 0) {
416
+ styles.info(
417
+ styles.dim(
418
+ `Pre-extracted ${extractedChangelog.length} changelog page${extractedChangelog.length === 1 ? '' : 's'} — bypassing organization, attaching as Changelog category at the end.`,
419
+ ),
420
+ )
421
+ }
422
+
423
+ // Dedupe items across llms.parsed.sections by URL (first occurrence wins).
424
+ // Some llms.txt files cross-reference the same page under multiple headings —
425
+ // feeding duplicate URLs into the organize step causes the same page to appear
426
+ // in multiple sidebar sections regardless of which path (direct, icons, full
427
+ // reorg) runs downstream.
428
+ if (llms) {
429
+ const seenItemUrls = new Set()
430
+ let deduped = 0
431
+ for (const section of llms.parsed.sections) {
432
+ const before = section.items.length
433
+ section.items = section.items.filter((item) => {
434
+ const key = normalizePath(item.url)
435
+ if (seenItemUrls.has(key)) return false
436
+ seenItemUrls.add(key)
437
+ return true
438
+ })
439
+ deduped += before - section.items.length
440
+ }
441
+ if (deduped > 0) {
442
+ styles.info(styles.dim(`Deduped ${deduped} cross-section duplicate URL${deduped === 1 ? '' : 's'} from llms.txt sections.`))
443
+ }
444
+ }
445
+
446
+ const dbgSuffix = `-${sourceUrl.hostname}`
131
447
  if (debugSnapshots) {
132
- debugSnapshots['01-llms-parsed.json'] = { llmsUrl, parsed: llms ? llms.parsed : null }
133
- debugSnapshots['01b-sitemap.json'] = { sitemapUrl, urls: sitemapKnownUrls }
448
+ debugSnapshots[`01-llms-parsed${dbgSuffix}.json`] = { llmsUrl, parsed: llms ? llms.parsed : null, skipped: skippedLlms }
449
+ debugSnapshots[`01b-sitemap${dbgSuffix}.json`] = { sitemapUrl, urls: sitemapKnownUrls }
450
+ debugSnapshots[`01c-extracted-changelog${dbgSuffix}.json`] = extractedChangelog
134
451
  }
135
452
 
136
453
  let knownUrls = []
137
454
  if (llms) {
138
455
  const totalItems = llms.parsed.sections.reduce((n, s) => n + s.items.length, 0)
456
+ const srcCount = llms.sourceFiles.length
457
+ const srcLabel = srcCount === 1 ? 'llms.txt' : `${srcCount} llms.txt files (merged)`
139
458
  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})` : ''}.`,
459
+ `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
460
  )
142
461
 
143
462
  const rawKnownUrls = llms.parsed.sections.flatMap((s) => s.items.map((i) => ({ title: i.text, url: i.url, description: i.description })))
@@ -145,13 +464,13 @@ export async function importDocs(options) {
145
464
  // Dedupe llms.txt entries by pathname. Some sites (zod.dev, fumadocs) list
146
465
  // every in-page anchor as its own llms.txt row (`/v4?id=wrapping-up`,
147
466
  // `/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.
467
+ // Prefer direct markdown source forms over shorter rendered URLs because
468
+ // they are better import sources for downstream content fetching.
150
469
  const byKnownPath = new Map()
151
470
  for (const p of rawKnownUrls) {
152
471
  const key = normalizePath(p.url)
153
472
  const prev = byKnownPath.get(key)
154
- if (!prev || p.url.length < prev.url.length) byKnownPath.set(key, p)
473
+ if (!prev || compareCanonicalUrlPreference(p.url, prev.url) < 0) byKnownPath.set(key, p)
155
474
  }
156
475
  knownUrls = Array.from(byKnownPath.values())
157
476
  const dropped = rawKnownUrls.length - knownUrls.length
@@ -172,56 +491,82 @@ export async function importDocs(options) {
172
491
  const mintlifyStart = Date.now()
173
492
  const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey))
174
493
  if (debugSnapshots) {
175
- debugSnapshots['02a-mintlify-nav.json'] = mintlifyNav ? JSON.parse(JSON.stringify(mintlifyNav)) : null
494
+ debugSnapshots[`02a-mintlify-nav${dbgSuffix}.json`] = mintlifyNav ? JSON.parse(JSON.stringify(mintlifyNav)) : null
176
495
  }
177
496
  if (mintlifyNav) {
178
- const pageCount = mintlifyNav.categories.reduce((n, c) => n + c.pages.length, 0)
497
+ const pageCount = mintlifyNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
179
498
  styles.ok(
180
499
  `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
500
  )
182
501
  }
183
502
  console.log()
184
503
 
504
+ // Archbee sites embed the canonical document tree in Next.js page data.
505
+ // Their llms.txt export can be a flat, shuffled list, so prefer the tree
506
+ // when present.
507
+ let archbeeNav = null
508
+ if (!mintlifyNav) {
509
+ styles.info(`Probing for Archbee document tree...`)
510
+ const archbeeStart = Date.now()
511
+ archbeeNav = await timePhase('archbee probe', () => tryArchbeeNav(sourceUrl.toString(), knownUrls, firecrawlKey))
512
+ if (archbeeNav) {
513
+ const pageCount = archbeeNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
514
+ styles.ok(
515
+ `Found Archbee document tree in ${styles.bold(formatDuration(Date.now() - archbeeStart))} — ${styles.bold(String(pageCount))} page${pageCount === 1 ? '' : 's'}.`,
516
+ )
517
+ }
518
+ console.log()
519
+ }
520
+
185
521
  let scraped
186
522
  let scrapeStart = Date.now()
523
+ const scrapeDiagnostics = {}
187
524
  if (mintlifyNav) {
188
525
  scraped = { title: mintlifyNav.title, categories: mintlifyNav.categories }
526
+ } else if (archbeeNav) {
527
+ scraped = { title: archbeeNav.title, categories: archbeeNav.categories }
189
528
  } else {
190
529
  styles.info(`Scraping sidebar nav from ${styles.bold(sourceUrl.toString())}${firecrawlKey ? ' ' + styles.dim('(via Firecrawl)') : ''}...`)
191
530
  scrapeStart = Date.now()
192
- scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey))
531
+ scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey, scrapeDiagnostics))
532
+ if (!scraped) scrapeDiagnostics.reason ??= 'unknown'
193
533
  }
194
534
  if (debugSnapshots) {
195
- debugSnapshots['02-scraped-raw.json'] = scraped ? JSON.parse(JSON.stringify(scraped)) : null
535
+ debugSnapshots[`02-scraped-raw${dbgSuffix}.json`] = scraped ? JSON.parse(JSON.stringify(scraped)) : null
196
536
  }
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.
537
+ // If the sidebar scrape covers less than 75% of the llms.txt URLs, the
538
+ // scrape is too thin to trust as the import's spine. Common on multi-tab
539
+ // docs (Stripe, AWS, Twilio, Xata) where each page only renders its own
540
+ // tab's sidebar — the visible categories would otherwise absorb hundreds
541
+ // of orphan URLs via prefix-matching and produce a misleading tree (e.g.
542
+ // every /docs/* URL dumped under a single "Overview > Xata Documentation"
543
+ // node because that's the one /docs page the scrape saw). Discard the
544
+ // scrape and fall through to the llms.txt path, which uses URL-based
545
+ // clustering when multiple files were merged.
546
+ let scrapeDiscardedForCoverage = false
202
547
  if (scraped && llms && knownUrls.length > 0) {
203
- const scrapedPages = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
548
+ const scrapedPages = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
204
549
  const coverage = scrapedPages / knownUrls.length
205
- const llmsUsable = usableSections(llms.parsed.sections)
206
- if (llmsUsable.length >= 5 && coverage < 0.5) {
550
+ if (coverage < 0.75) {
207
551
  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.`,
552
+ `Scrape covered ${styles.bold(Math.round(coverage * 100) + '%')} of llms.txt pages (need ≥75%) — discarding scrape and organizing from llms.txt.`,
209
553
  )
210
554
  scraped = null
555
+ scrapeDiscardedForCoverage = true
211
556
  }
212
557
  }
213
558
 
214
559
  if (scraped) {
215
- const directMatches = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
560
+ const directMatches = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
216
561
  if (knownUrls.length > 0) {
217
562
  const slotted = slotOrphansByPath(scraped, knownUrls)
218
563
  if (debugSnapshots) {
219
- debugSnapshots['03-after-slot-by-path.json'] = {
564
+ debugSnapshots[`03-after-slot-by-path${dbgSuffix}.json`] = {
220
565
  scraped: JSON.parse(JSON.stringify(scraped)),
221
566
  unslottedOrphans: slotted,
222
567
  }
223
568
  }
224
- const totalMatched = scraped.categories.reduce((n, c) => n + c.pages.length, 0)
569
+ const totalMatched = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
225
570
  styles.ok(
226
571
  `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
572
  )
@@ -235,40 +580,83 @@ export async function importDocs(options) {
235
580
  styles.info(`Moved ${styles.bold(String(moved))} page${moved === 1 ? '' : 's'} into ${styles.bold('API Reference')} based on URL path.`)
236
581
  }
237
582
 
583
+ // Same idea for changelog / release-notes pages — pull any whose URL
584
+ // carries a changelog-style segment into one "Changelog" category. It
585
+ // routes to docs/Changelog/ unless --separate-changelog relocates it.
586
+ const movedChangelog = reclassifyChangelogPages(scraped)
587
+ if (movedChangelog > 0) {
588
+ styles.info(`Moved ${styles.bold(String(movedChangelog))} page${movedChangelog === 1 ? '' : 's'} into ${styles.bold('Changelog')} based on URL path.`)
589
+ }
590
+
238
591
  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
- ? {
592
+ // When orphans dwarf direct matches, the sidebar scrape was too thin
593
+ // to trust as the import's spine — keeping it would produce a small
594
+ // "real" tree plus a soup of bucketed-by-URL-type orphan categories.
595
+ // Discard the scrape and cluster every page (scrape + orphans) by
596
+ // its top URL segment instead, so each `/docs/`, `/sdk/`, `/rest-api/`
597
+ // becomes its own category. `nestByUrlHierarchy` (later) handles the
598
+ // empty-parent nesting within each category.
599
+
600
+ // Trip when orphans are at least 2× the direct matches: the scrape
601
+ // accounts for less than a third of the known pages, so its category
602
+ // labels aren't a trustworthy spine for the remainder.
603
+ const orphansDwarfDirect = slotted.length >= directMatches * 2
604
+ const scrapeAllPages = scraped.categories.flatMap((c) => collectUrlPagesDeep(c.pages))
605
+ let reclustered = null
606
+ if (orphansDwarfDirect) {
607
+ reclustered = clusterByUrlPath([...scrapeAllPages, ...slotted])
608
+ }
609
+ styles.info(
610
+ styles.dim(
611
+ ` 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'}`,
612
+ ),
613
+ )
614
+ if (reclustered) {
615
+ scraped.categories = reclustered.map((c) => ({ title: c.title, pages: c.pages }))
616
+ styles.info(
617
+ `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(', ')}.`,
618
+ )
619
+ if (debugSnapshots) {
620
+ debugSnapshots[`04-after-orphan-buckets${dbgSuffix}.json`] = {
621
+ mode: 'reclustered-by-url-path',
622
+ scraped: JSON.parse(JSON.stringify(scraped)),
623
+ }
624
+ }
625
+ } else {
626
+ // Mintlify-style docs put API endpoints in a separate tab rooted at
627
+ // /api-reference/* (or /api/*, /reference/*). Collapse remaining such
628
+ // pages into a single "API Reference" category (absorbing the flat
629
+ // category the sweep pass just built, if any) and nest it by resource
630
+ // segment so routeCategory() maps the whole thing to ReadMe's
631
+ // `reference/` top-level dir.
632
+ const apiResult = collectApiReferencePages(slotted, scraped)
633
+ const otherOrphans = apiResult.nonApiOrphans
634
+ if (apiResult.category) scraped.categories.push(apiResult.category)
635
+
636
+ const buckets = bucketOrphansByPathType(otherOrphans, scraped)
637
+ for (const b of buckets) scraped.categories.push(b)
638
+ if (debugSnapshots) {
639
+ debugSnapshots[`04-after-orphan-buckets${dbgSuffix}.json`] = {
640
+ apiReferenceCollected: apiResult.category
641
+ ? {
255
642
  pageCount: apiResult.category.pages.length,
256
643
  mergedFromScraped: apiResult.mergedScrapedTitles,
257
644
  }
258
- : null,
259
- buckets,
260
- scraped: JSON.parse(JSON.stringify(scraped)),
645
+ : null,
646
+ buckets,
647
+ scraped: JSON.parse(JSON.stringify(scraped)),
648
+ }
649
+ }
650
+ const parts = []
651
+ if (apiResult.category) {
652
+ parts.push(`${styles.bold(String(apiResult.category.pages.length))} in ${styles.bold('API Reference')}`)
653
+ }
654
+ for (const b of buckets) {
655
+ parts.push(`${styles.bold(String(b.pages.length))} in ${styles.bold(b.title)}`)
656
+ }
657
+ if (parts.length > 0) {
658
+ styles.info(`${styles.bold(String(slotted.length))} orphan page${slotted.length === 1 ? '' : 's'} bucketed by URL type: ${parts.join(', ')}.`)
261
659
  }
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
660
  }
273
661
  }
274
662
  } else {
@@ -298,9 +686,31 @@ export async function importDocs(options) {
298
686
  } else if (!llms && knownUrls.length === 0) {
299
687
  throw new Error(`No llms.txt or sitemap.xml and the sidebar scrape found no usable structure — can't import ${sourceUrl.toString()}.`)
300
688
  } else if (llms) {
301
- styles.warning(`Couldn't extract a useful nav — falling back to llms.txt-based organization.`)
689
+ if (!scrapeDiscardedForCoverage) {
690
+ const d = scrapeDiagnostics || {}
691
+ if (d.reason === 'no-categories-round0') {
692
+ styles.warning(`Sidebar scrape found no nav structure on the round-0 fetch — falling back to llms.txt organization.`)
693
+ } else if (d.reason === 'below-threshold') {
694
+ styles.warning(
695
+ `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.`,
696
+ )
697
+ } else {
698
+ styles.warning(`Sidebar scrape returned no usable nav — falling back to llms.txt organization.`)
699
+ }
700
+ }
302
701
  } else {
303
- styles.warning(`Couldn't extract a useful nav — falling back to sitemap URL clustering.`)
702
+ if (!scrapeDiscardedForCoverage) {
703
+ const d = scrapeDiagnostics || {}
704
+ if (d.reason === 'no-categories-round0') {
705
+ styles.warning(`Sidebar scrape found no nav structure on the round-0 fetch — falling back to sitemap URL clustering.`)
706
+ } else if (d.reason === 'below-threshold') {
707
+ styles.warning(
708
+ `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.`,
709
+ )
710
+ } else {
711
+ styles.warning(`Sidebar scrape returned no usable nav — falling back to sitemap URL clustering.`)
712
+ }
713
+ }
304
714
  }
305
715
  console.log()
306
716
 
@@ -314,9 +724,60 @@ export async function importDocs(options) {
314
724
  categories: scraped.categories.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
315
725
  }
316
726
  } 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))
727
+ if (llms.sourceFiles.length > 1) {
728
+ // Multi-file merge: H2 section headings across the merged files are
729
+ // heterogeneous and don't form a coherent spine (e.g. elevenlabs.io's
730
+ // root file uses `Docs`/`Products` while /docs/llms.txt uses
731
+ // `API Docs` — concatenating them produces meaningless top-level
732
+ // categories like `docs/Docs`, `docs/Products`, `docs/API Docs`).
733
+ // Throw away the section grouping and re-cluster by URL path instead.
734
+ // Per-page metadata (title, url, description) is still carried over.
735
+ const flatPages = llms.parsed.sections.flatMap((s) =>
736
+ s.items.map((it) => ({
737
+ title: it.text,
738
+ url: it.url,
739
+ ...(it.description ? { description: it.description } : {}),
740
+ })),
741
+ )
742
+ const clustered = clusterByUrlPath(flatPages) || [{ title: 'Documentation', pages: flatPages }]
743
+ styles.info(
744
+ `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).`,
745
+ )
746
+ organized = {
747
+ title: llms.parsed.title || null,
748
+ categories: clustered.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
749
+ }
750
+ } else {
751
+ // Single-file mode: H2 structure IS the author's intended organization,
752
+ // so trust it. Use sections directly when usable; otherwise run Claude.
753
+ const usable = usableSections(llms.parsed.sections)
754
+ const avgPages = usable.length > 0 ? usable.reduce((n, s) => n + s.items.length, 0) / usable.length : 0
755
+ if (sectionsLookUsable(llms.parsed.sections) && avgPages < 50) {
756
+ styles.info(
757
+ `Using llms.txt sections directly — ${styles.bold(String(usable.length))} sections, ${styles.bold(avgPages.toFixed(1))} avg pages/section (≤50). Skipping Claude.`,
758
+ )
759
+ organized = {
760
+ title: llms.parsed.title || null,
761
+ categories: usable.map((s) => ({
762
+ title: s.title,
763
+ icon: null,
764
+ pages: s.items.map((it) => ({
765
+ title: it.text,
766
+ url: it.url,
767
+ ...(it.description ? { description: it.description } : {}),
768
+ })),
769
+ })),
770
+ }
771
+ } else {
772
+ const fastPath = sectionsLookUsable(llms.parsed.sections)
773
+ styles.info(`Organizing with Claude (${styles.bold(options.model)}, ${fastPath ? 'fast path: icons only' : 'full reorg'})...`)
774
+ organized = await timePhase('claude organize', () => organizeWithClaude(llms.parsed, options.model))
775
+ const movedRef = reclassifyReferencePages(organized)
776
+ if (movedRef > 0) {
777
+ styles.info(`Moved ${styles.bold(String(movedRef))} page${movedRef === 1 ? '' : 's'} into ${styles.bold('API Reference')} based on URL path.`)
778
+ }
779
+ }
780
+ }
320
781
  } else {
321
782
  // Sitemap-only fallback: synthesize categories by clustering the URL
322
783
  // paths. clusterByUrlPath returns null when the URLs don't split cleanly,
@@ -327,75 +788,66 @@ export async function importDocs(options) {
327
788
  categories: clustered.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
328
789
  }
329
790
  }
330
-
331
- for (const cat of organized.categories || []) {
332
- cat.pages = nestByUrlHierarchy(cat.pages)
333
- }
334
791
 
335
- 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()
792
+ // Re-attach the pre-extracted changelog pages as their own Changelog
793
+ // category. Done before nestByUrlHierarchy so the injected pages get nested
794
+ // alongside the rest of the tree for consistency. Merges into a pre-existing
795
+ // Changelog category (scrape path's reclassifier may have already built one).
796
+ injectChangelogCategory(organized, extractedChangelog)
797
+
798
+ // Import section landing pages (e.g. /docs/cli) that llms.txt models as a
799
+ // bare heading + child links and so never lists as a page of their own.
800
+ // Runs before nesting so the injected page lands at its trie node and
801
+ // nestByUrlHierarchy renders it as the real parent of its children.
802
+ await injectSectionLandingPages(organized, sourceUrl)
346
803
 
347
- console.log(` ${styles.bold(organized.title || '(untitled)')}`)
348
804
  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)
805
+ cat.pages = nestByUrlHierarchy(cat.pages)
353
806
  }
354
807
 
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))}.`,
365
- )
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
- }
808
+ styles.ok(`Organized in ${styles.bold(formatDuration(Date.now() - organizeStart))}.`)
809
+ organized.oasJsonUrls = oasJsonUrls
379
810
 
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 }
811
+ // Record which page URLs came from llms.txt
812
+ const llmsPaths = new Set()
813
+ if (llms) {
814
+ for (const p of [...knownUrls, ...extractedChangelog]) {
815
+ if (p.url) llmsPaths.add(normalizePath(p.url))
383
816
  }
817
+ }
818
+ organized.llmsPaths = llmsPaths
819
+ return organized
820
+ }
384
821
 
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 })
822
+ /**
823
+ * Merge per-source `organized` results into one. Same-titled categories MERGE
824
+ * their page lists (intentional — when a user bundles related sources into
825
+ * one project, they probably want a coherent sidebar; `ensureUniqueSlugs` later
826
+ * dedupes any slug collisions globally). First non-null title wins; first
827
+ * non-null icon per merged category wins. Page order within a merged category
828
+ * is the concatenation of input sources in argument order.
829
+ */
830
+ function mergeOrganized(perSource) {
831
+ const title = perSource.map((o) => o.title).find(Boolean) || null
832
+
833
+ const byTitle = new Map()
834
+ const order = []
835
+ for (const o of perSource) {
836
+ for (const cat of o.categories || []) {
837
+ const key = cat.title
838
+ const existing = byTitle.get(key)
839
+ if (existing) {
840
+ existing.pages.push(...(cat.pages || []))
841
+ if (!existing.icon && cat.icon) existing.icon = cat.icon
842
+ } else {
843
+ const copy = { ...cat, pages: [...(cat.pages || [])] }
844
+ byTitle.set(key, copy)
845
+ order.push(copy)
846
+ }
395
847
  }
396
848
  }
397
849
 
398
- return result
850
+ return { title, categories: order }
399
851
  }
400
852
 
401
853
  /**
@@ -829,7 +1281,7 @@ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
829
1281
  function extractMintlifyConfig(body) {
830
1282
  try {
831
1283
  return JSON.parse(body)
832
- } catch {}
1284
+ } catch { }
833
1285
  // Pull out the first balanced `{ ... }` that contains a "navigation" key.
834
1286
  const navIdx = body.indexOf('"navigation"')
835
1287
  if (navIdx === -1) return null
@@ -840,7 +1292,7 @@ function extractMintlifyConfig(body) {
840
1292
  try {
841
1293
  const parsed = JSON.parse(candidate)
842
1294
  if (parsed && parsed.navigation) return parsed
843
- } catch {}
1295
+ } catch { }
844
1296
  }
845
1297
  start = body.lastIndexOf('{', start - 1)
846
1298
  }
@@ -928,20 +1380,95 @@ function parseMintlifyConfig(config, origin, byPath) {
928
1380
  }
929
1381
 
930
1382
  /**
931
- * Score a parsed nav tree for "sidebar-likeness". A real docs sidebar has
932
- * multiple section headers (hierarchy) and tens of links; secondary navs
933
- * (sitemaps, footers, search-index result lists) tend to be flat single-
934
- * category blobs that are either alphabetized or dumped in insertion order.
1383
+ * Archbee exposes its canonical docs tree in Next.js page data as
1384
+ * `_docSpace.publicDocsTree`. That tree preserves author order and nesting,
1385
+ * while Archbee's llms.txt export may be flat and shuffled.
935
1386
  *
936
- * Flat single-category blocks are penalized so a slightly smaller hierarchical
937
- * block wins over a bigger flat one. Without this, greenflash.ai's sidebar
938
- * order was non-deterministic across runs — sometimes Quickstart first (real
939
- * sidebar won), sometimes last (a flat alphabetized index block won).
1387
+ * Returns { title, categories } or null if the page is not Archbee or the
1388
+ * embedded tree is unavailable.
940
1389
  */
941
- function scoreNavTree(tree) {
942
- const count = tree.categories.reduce((n, c) => n + c.pages.length, 0)
943
- const cats = tree.categories.length
944
- const hierarchyBonus = cats >= 2 ? cats * 5 : -20
1390
+ async function tryArchbeeNav(sourceUrl, knownPages, firecrawlKey) {
1391
+ const origin = new URL(sourceUrl).origin
1392
+ const fetchHtml = firecrawlKey ? makeFirecrawlFetcher(firecrawlKey) : fetchHtmlDirect
1393
+ const html = await fetchHtml(toBrowsableUrl(sourceUrl))
1394
+ if (!html || !/publicDocsTree|Archbee/i.test(html)) return null
1395
+
1396
+ const nextData = extractNextData(html)
1397
+ const docSpace = nextData?.props?.pageProps?._docSpace
1398
+ const tree = docSpace?.publicDocsTree
1399
+ if (!Array.isArray(tree) || tree.length === 0) return null
1400
+
1401
+ const byPath = new Map()
1402
+ for (const p of knownPages) byPath.set(normalizePath(p.url), p)
1403
+
1404
+ const pageFromNode = (node) => {
1405
+ if (!node || typeof node !== 'object') return null
1406
+ if (node.isHidden === true) return null
1407
+
1408
+ const title = String(node.name || node.title || node.urlKey || '').trim()
1409
+ const children = Array.isArray(node.children) ? node.children.map(pageFromNode).filter(Boolean) : []
1410
+ if (!title && children.length === 0) return null
1411
+
1412
+ const url = archbeeNodeUrl(origin, node.urlKey)
1413
+ const known = url ? byPath.get(normalizePath(url)) : null
1414
+ const page = {
1415
+ title: known?.title || title || 'Untitled',
1416
+ url,
1417
+ ...(known?.description ? { description: known.description } : {}),
1418
+ ...(children.length > 0 ? { pages: children } : {}),
1419
+ }
1420
+
1421
+ // Root docs pages have no URL path segments, but should stay in their
1422
+ // authored position when the later URL-trie nesting pass runs.
1423
+ if (url && extractUrlPathSegments(url).length === 0) {
1424
+ page._virtualPathSegs = [kebabCase(page.title || 'home') || 'home']
1425
+ }
1426
+
1427
+ return page
1428
+ }
1429
+
1430
+ const pages = tree.map(pageFromNode).filter(Boolean)
1431
+ if (pages.length === 0) return null
1432
+
1433
+ return {
1434
+ title: docSpace.hostingTitle || docSpace.name || null,
1435
+ categories: [{ title: 'Documentation', pages }],
1436
+ }
1437
+ }
1438
+
1439
+ function extractNextData(html) {
1440
+ const m = String(html).match(/<script\b(?=[^>]*\bid=["']__NEXT_DATA__["'])[^>]*>([\s\S]*?)<\/script>/i)
1441
+ if (!m) return null
1442
+ try {
1443
+ return JSON.parse(m[1])
1444
+ } catch {
1445
+ return null
1446
+ }
1447
+ }
1448
+
1449
+ function archbeeNodeUrl(origin, urlKey) {
1450
+ if (urlKey === undefined || urlKey === null) return null
1451
+ const key = String(urlKey || '').trim()
1452
+ if (!key || key === '/') return `${origin}/`
1453
+ if (/^https?:\/\//i.test(key)) return key
1454
+ return `${origin}/${key.replace(/^\/+/, '')}`
1455
+ }
1456
+
1457
+ /**
1458
+ * Score a parsed nav tree for "sidebar-likeness". A real docs sidebar has
1459
+ * multiple section headers (hierarchy) and tens of links; secondary navs
1460
+ * (sitemaps, footers, search-index result lists) tend to be flat single-
1461
+ * category blobs that are either alphabetized or dumped in insertion order.
1462
+ *
1463
+ * Flat single-category blocks are penalized so a slightly smaller hierarchical
1464
+ * block wins over a bigger flat one. Without this, greenflash.ai's sidebar
1465
+ * order was non-deterministic across runs — sometimes Quickstart first (real
1466
+ * sidebar won), sometimes last (a flat alphabetized index block won).
1467
+ */
1468
+ function scoreNavTree(tree) {
1469
+ const count = tree.categories.reduce((n, c) => n + c.pages.length, 0)
1470
+ const cats = tree.categories.length
1471
+ const hierarchyBonus = cats >= 2 ? cats * 5 : -20
945
1472
  // Alphabetical penalty: real sidebars are curated (Overview first, related
946
1473
  // topics grouped). Auto-generated indexes, search clouds, and footer link
947
1474
  // lists tend to be strictly alphabetized. When a category's pages come in
@@ -970,7 +1497,7 @@ function isMonotonicAlpha(titles) {
970
1497
  * renders its sidebar server-side as <nav>/<aside> with <h*> section headers.
971
1498
  * Returns null if coverage is too low to be useful.
972
1499
  */
973
- async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1500
+ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostics = {}) {
974
1501
  // Index known pages by normalized pathname so we can match nav hrefs against them.
975
1502
  const byPath = new Map()
976
1503
  for (const p of knownPages) byPath.set(normalizePath(p.url), p)
@@ -1106,7 +1633,10 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1106
1633
  const r0Start = Date.now()
1107
1634
  await visit(sourceUrl)
1108
1635
  const r0Ms = Date.now() - r0Start
1109
- if (categoryOrder.length === 0) return null
1636
+ if (categoryOrder.length === 0) {
1637
+ diagnostics.reason = 'no-categories-round0'
1638
+ return null
1639
+ }
1110
1640
 
1111
1641
  // Round 1 (parallel): visit pages so each branch has a chance to expose
1112
1642
  // its sub-items. Sidebars on most docs sites auto-expand the current
@@ -1123,9 +1653,9 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1123
1653
  const r1Urls = isDiscovery
1124
1654
  ? flattenTree(categoryOrder).slice(0, MAX_DISCOVERY_FETCHES)
1125
1655
  : categoryOrder
1126
- .map((c) => c.pages[0])
1127
- .filter(Boolean)
1128
- .map((p) => toBrowsableUrl(p.url))
1656
+ .map((c) => c.pages[0])
1657
+ .filter(Boolean)
1658
+ .map((p) => toBrowsableUrl(p.url))
1129
1659
  const r1Start = Date.now()
1130
1660
  // Firecrawl standard-plan concurrency is 10; 5 leaves headroom for retries.
1131
1661
  // Native HTTP can run hotter since we're hitting our own loopback.
@@ -1135,14 +1665,44 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1135
1665
  styles.dim(` ⏱ scrape breakdown: round0=${formatDuration(r0Ms)} round1=${formatDuration(r1Ms)} (${r1Urls.length} ${isDiscovery ? 'discovery' : 'category rep'} fetches)`),
1136
1666
  )
1137
1667
 
1668
+ // Prune login/account chrome now that crawling is done. On Stoplight sites
1669
+ // the "Sign in" → /auth link is the only seed on the bare landing page, so we
1670
+ // had to keep it discoverable to reach the real sidebar — but it's not a doc.
1671
+ const pruneAuthPages = (pages) => {
1672
+ let removed = 0
1673
+ for (let i = pages.length - 1; i >= 0; i--) {
1674
+ const page = pages[i]
1675
+ if (page.pages && page.pages.length > 0) removed += pruneAuthPages(page.pages)
1676
+ if (isAuthChromeUrl(page.url) && (!page.pages || page.pages.length === 0)) {
1677
+ matched.delete(normalizePath(page.url))
1678
+ pages.splice(i, 1)
1679
+ removed++
1680
+ }
1681
+ }
1682
+ return removed
1683
+ }
1684
+ for (const cat of categoryOrder) pruneAuthPages(cat.pages)
1685
+
1138
1686
  // Accept thresholds — looser in discovery mode (no llms.txt) where even a
1139
1687
  // single flat "Overview" bucket is better than nothing, stricter when we
1140
1688
  // have llms.txt to compare against.
1141
1689
  const categories = categoryOrder.filter((c) => c.pages.length > 0)
1142
1690
  if (isDiscovery) {
1143
- if (categories.length < 1 || matched.size < 5) return null
1691
+ if (categories.length < 1 || matched.size < 5) {
1692
+ diagnostics.reason = 'below-threshold'
1693
+ diagnostics.categories = categories.length
1694
+ diagnostics.matched = matched.size
1695
+ diagnostics.need = '≥1 category, ≥5 matched pages'
1696
+ return null
1697
+ }
1144
1698
  } else {
1145
- if (categories.length < 2 || matched.size < 10) return null
1699
+ if (categories.length < 2 || matched.size < 10) {
1700
+ diagnostics.reason = 'below-threshold'
1701
+ diagnostics.categories = categories.length
1702
+ diagnostics.matched = matched.size
1703
+ diagnostics.need = '≥2 categories, ≥10 matched pages'
1704
+ return null
1705
+ }
1146
1706
  }
1147
1707
  return { title: null, categories }
1148
1708
  }
@@ -1167,12 +1727,17 @@ function extractSidebarContainers(html) {
1167
1727
  const SIDEBAR_ATTR_RE =
1168
1728
  /\b(?:id|class|aria-label|data-testid)=(?:"[^"]*sidebar[^"]*"|'[^']*sidebar[^']*'|"[^"]*navigation[^"]*"|'[^']*navigation[^']*')|\brole=(?:"navigation"|'navigation')/i
1169
1729
 
1730
+ // Layout-wrapper modifiers like `Page--with-sidebar` / `has-sidebar` describe
1731
+ // an element that CONTAINS a sidebar, not one that IS a sidebar.
1732
+ const WRAPPER_MODIFIER_RE = /(?:^|[\s"'-])(?:with|has)-sidebar(?=[\s"']|$)/i
1733
+
1170
1734
  const out = []
1171
1735
  for (const tag of SIDEBAR_TAGS) {
1172
1736
  const openRe = new RegExp(`<${tag}\\b([^>]*)>`, 'gi')
1173
1737
  let m
1174
1738
  while ((m = openRe.exec(html)) !== null) {
1175
1739
  if (!SIDEBAR_ATTR_RE.test(m[1])) continue
1740
+ if (WRAPPER_MODIFIER_RE.test(m[1])) continue
1176
1741
  const inner = extractBalancedTag(html, tag, m.index + m[0].length)
1177
1742
  if (inner != null && inner.length > 100) out.push(inner)
1178
1743
  }
@@ -1383,7 +1948,7 @@ function collectApiReferencePages(orphans, scraped) {
1383
1948
  let segs = []
1384
1949
  try {
1385
1950
  segs = new URL(p.url).pathname.split('/').filter(Boolean)
1386
- } catch {}
1951
+ } catch { }
1387
1952
  // segs[0] is the api-prefix itself; segs[1] (if any) is the resource.
1388
1953
  const resource = segs.length >= 3 ? segs[1] : null
1389
1954
  if (!resource) {
@@ -1439,9 +2004,9 @@ function bucketOrphansByPathType(orphans, scraped) {
1439
2004
  endpoints: 'API Reference',
1440
2005
  endpoint: 'API Reference',
1441
2006
  changelog: 'Changelog',
1442
- release: 'Release Notes',
1443
- releases: 'Release Notes',
1444
- 'release-notes': 'Release Notes',
2007
+ release: 'Changelog',
2008
+ releases: 'Changelog',
2009
+ 'release-notes': 'Changelog',
1445
2010
  recipes: 'Recipes',
1446
2011
  recipe: 'Recipes',
1447
2012
  guides: 'Guides',
@@ -1534,43 +2099,42 @@ function titleCase(s) {
1534
2099
 
1535
2100
  /**
1536
2101
  * 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.
2102
+ * carries one of `segmentRe`'s path segments into a single category,
2103
+ * flattening sub-pages as it goes. Shared backbone for the API Reference and
2104
+ * Changelog sweeps: both favor the URL-path signal over sidebar placement, so
2105
+ * e.g. `/api-reference/*` or `/changelog/*` pages land together in their own
2106
+ * category even when a site's sidebar spotlights a few of them elsewhere.
1543
2107
  *
1544
- * Returns the number of pages relocated.
2108
+ * `categoryRe` matches an existing category title to merge into (so we don't
2109
+ * create a duplicate); when none matches, a fresh `{ title: defaultTitle }`
2110
+ * category is appended. Returns the number of pages relocated.
1545
2111
  */
1546
- function reclassifyReferencePages(scraped) {
1547
- const REFERENCE_SEGMENT = /^(api[-_]?reference|endpoints?)$/i
2112
+ function reclassifyPagesByUrlSegment(scraped, { segmentRe, categoryRe, defaultTitle }) {
1548
2113
  const normTitle = (t) =>
1549
2114
  String(t || '')
1550
2115
  .replace(INVISIBLE_CHARS, '')
1551
2116
  .trim()
1552
2117
  .toLowerCase()
1553
2118
 
1554
- const looksLikeRefUrl = (url) => {
2119
+ const urlHasSegment = (url) => {
1555
2120
  try {
1556
2121
  const segs = new URL(url).pathname.split('/').filter(Boolean)
1557
- return segs.some((s) => REFERENCE_SEGMENT.test(s))
2122
+ return segs.some((s) => segmentRe.test(s))
1558
2123
  } catch {
1559
2124
  return false
1560
2125
  }
1561
2126
  }
1562
2127
 
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)
2128
+ // Find (or create) the canonical destination category. Prefer an existing
2129
+ // one with a matching title so we don't end up with duplicates.
2130
+ let destCat = scraped.categories.find((c) => categoryRe.test(normTitle(c.title).replace(/\s+/g, ' ')))
1567
2131
 
1568
2132
  const collected = []
1569
2133
  const filterPages = (pages) => {
1570
2134
  const kept = []
1571
2135
  for (const p of pages || []) {
1572
- if (looksLikeRefUrl(p.url)) {
1573
- // Flatten sub-pages when relocating — API Reference is a flat list.
2136
+ if (urlHasSegment(p.url)) {
2137
+ // Flatten sub-pages when relocating — these sections are flat lists.
1574
2138
  collectFlat(p, collected)
1575
2139
  continue
1576
2140
  }
@@ -1580,32 +2144,145 @@ function reclassifyReferencePages(scraped) {
1580
2144
  return kept
1581
2145
  }
1582
2146
 
1583
- // Never pull pages out of the reference category itself.
2147
+ // Never pull pages out of the destination category itself.
1584
2148
  for (const cat of scraped.categories) {
1585
- if (cat === refCat) continue
2149
+ if (cat === destCat) continue
1586
2150
  cat.pages = filterPages(cat.pages)
1587
2151
  }
1588
2152
 
1589
2153
  if (collected.length === 0) return 0
1590
2154
 
1591
- if (!refCat) {
1592
- refCat = { title: 'API Reference', pages: [] }
1593
- scraped.categories.push(refCat)
2155
+ if (!destCat) {
2156
+ destCat = { title: defaultTitle, pages: [] }
2157
+ scraped.categories.push(destCat)
1594
2158
  }
1595
- // Dedupe against anything already in the reference category.
1596
- const seen = new Set(refCat.pages.map((p) => normalizePath(p.url)))
2159
+ // Dedupe against anything already in the destination category.
2160
+ const seen = new Set(destCat.pages.map((p) => normalizePath(p.url)))
1597
2161
  for (const p of collected) {
1598
2162
  const key = normalizePath(p.url)
1599
2163
  if (seen.has(key)) continue
1600
2164
  seen.add(key)
1601
- refCat.pages.push(p)
2165
+ destCat.pages.push(p)
2166
+ }
2167
+
2168
+ // Drop now-empty categories (other than the one we may have just created).
2169
+ scraped.categories = scraped.categories.filter((c) => c === destCat || (c.pages && c.pages.length > 0))
2170
+
2171
+ return collected.length
2172
+ }
2173
+
2174
+ /**
2175
+ * Sweep `/api-reference/*` and `/endpoints/*` pages into one "API Reference"
2176
+ * category. Some docs sites (e.g. greenflash.ai) spotlight a handful of
2177
+ * endpoints under "Developers" in the sidebar while the bulk of endpoints
2178
+ * live under a separate API Reference section — this lands them all in
2179
+ * `reference/` after staging. Returns the number of pages relocated.
2180
+ */
2181
+ function reclassifyReferencePages(scraped) {
2182
+ return reclassifyPagesByUrlSegment(scraped, {
2183
+ segmentRe: /^(api[-_]?reference|endpoints?)$/i,
2184
+ categoryRe: /^(api[ -]?reference|reference|api|endpoints?)$/i,
2185
+ defaultTitle: 'API Reference',
2186
+ })
2187
+ }
2188
+
2189
+ // Anchored half covers ambiguous bare keywords (release, releases, versioned
2190
+ // release-2026-2) — those need to be the whole segment so we don't sweep
2191
+ // `/release-pipeline/` etc. Unanchored half covers the unambiguous keywords
2192
+ // (changelog, release-notes, whats-new), which we catch as substrings so
2193
+ // slugs like `/docs/changelog-javascript-agent` and `/docs/ios-sdk-changelog`
2194
+ // get pulled in too.
2195
+ const CHANGELOG_URL_SEGMENT_RE = /(?:^(?:releases?|release[-_]?v?\d[\w.-]*)$)|(?:changelog|change[-_]?log|release[-_]?notes?|whats?[-_]?new)/i
2196
+ const CHANGELOG_CATEGORY_TITLE_RE = /^(changelog|change ?log|release ?notes?|releases?|what'?s ?new)$/i
2197
+
2198
+ function urlIsChangelog(url) {
2199
+ try {
2200
+ const segs = new URL(url).pathname.split('/').filter(Boolean)
2201
+ return segs.some((s) => CHANGELOG_URL_SEGMENT_RE.test(s))
2202
+ } catch {
2203
+ return false
2204
+ }
2205
+ }
2206
+
2207
+ /**
2208
+ * Pull changelog items OUT of llms.parsed.sections in place. Done before the
2209
+ * organize pipeline runs so AI / clustering never sees them — they get
2210
+ * re-attached as a dedicated Changelog category at the end via
2211
+ * injectChangelogCategory. Sections that go empty afterwards are filtered out
2212
+ * naturally by `usableSections` downstream.
2213
+ *
2214
+ * Returns the extracted items in `{ title, url, description? }` shape.
2215
+ */
2216
+ function extractChangelogFromSections(sections) {
2217
+ const extracted = []
2218
+ for (const section of sections || []) {
2219
+ const kept = []
2220
+ for (const item of section.items || []) {
2221
+ if (urlIsChangelog(item.url)) {
2222
+ extracted.push({
2223
+ title: item.text,
2224
+ url: item.url,
2225
+ ...(item.description ? { description: item.description } : {}),
2226
+ })
2227
+ } else {
2228
+ kept.push(item)
2229
+ }
2230
+ }
2231
+ section.items = kept
2232
+ }
2233
+ return extracted
2234
+ }
2235
+
2236
+ /**
2237
+ * Split a flat known-URL list into changelog vs. the rest. Used for the
2238
+ * sitemap-only fallback, where there's no llms.txt section structure to mutate.
2239
+ */
2240
+ function partitionChangelogFromKnownUrls(knownUrls) {
2241
+ const extracted = []
2242
+ const kept = []
2243
+ for (const p of knownUrls || []) {
2244
+ if (urlIsChangelog(p.url)) extracted.push(p)
2245
+ else kept.push(p)
1602
2246
  }
2247
+ return { extracted, kept }
2248
+ }
1603
2249
 
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))
2250
+ /**
2251
+ * Attach pre-extracted changelog items as a dedicated Changelog category on
2252
+ * the final organized tree. Merges into an existing Changelog-titled category
2253
+ * if one is already present (e.g. the scrape path's reclassifier already built
2254
+ * one); otherwise creates a fresh one. Dedupe is by normalized URL path.
2255
+ */
2256
+ function injectChangelogCategory(organized, items) {
2257
+ if (!items || items.length === 0) return
2258
+ organized.categories = organized.categories || []
2259
+ let dest = organized.categories.find((c) => CHANGELOG_CATEGORY_TITLE_RE.test(String(c.title || '').trim()))
2260
+ if (!dest) {
2261
+ dest = { title: 'Changelog', icon: null, pages: [] }
2262
+ organized.categories.push(dest)
2263
+ }
2264
+ const seen = new Set((dest.pages || []).map((p) => normalizePath(p.url)))
2265
+ for (const item of items) {
2266
+ const key = normalizePath(item.url)
2267
+ if (seen.has(key)) continue
2268
+ seen.add(key)
2269
+ dest.pages.push(item)
2270
+ }
2271
+ }
1606
2272
 
1607
- // If the category existed before but the relocation was a no-op, surface 0.
1608
- return existedBefore ? collected.length : collected.length
2273
+ /**
2274
+ * Sweep `/changelog/*`, `/release-notes/*` and `/releases/*` pages into one
2275
+ * "Changelog" category, wherever the site's sidebar happened to place them.
2276
+ * That category routes to `docs/Changelog/`; `--separate-changelog` later
2277
+ * relocates it to a top-level `changelog/` dir. Returns the number of pages
2278
+ * relocated.
2279
+ */
2280
+ function reclassifyChangelogPages(scraped) {
2281
+ return reclassifyPagesByUrlSegment(scraped, {
2282
+ segmentRe: CHANGELOG_URL_SEGMENT_RE,
2283
+ categoryRe: CHANGELOG_CATEGORY_TITLE_RE,
2284
+ defaultTitle: 'Changelog',
2285
+ })
1609
2286
  }
1610
2287
 
1611
2288
  function collectFlat(page, out) {
@@ -1619,71 +2296,168 @@ function collectFlat(page, out) {
1619
2296
  }
1620
2297
  }
1621
2298
 
2299
+ function countUrlPagesDeep(pages) {
2300
+ let n = 0
2301
+ for (const p of pages || []) {
2302
+ if (p.url) n++
2303
+ if (p.pages && p.pages.length > 0) n += countUrlPagesDeep(p.pages)
2304
+ }
2305
+ return n
2306
+ }
2307
+
2308
+ function collectUrlPagesDeep(pages, out = []) {
2309
+ for (const p of pages || []) {
2310
+ if (p.url) out.push(p)
2311
+ if (p.pages && p.pages.length > 0) collectUrlPagesDeep(p.pages, out)
2312
+ }
2313
+ return out
2314
+ }
2315
+
2316
+ // urlTrieSegs is imported from ../utils/url-segs.js
2317
+
1622
2318
  /**
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.
2319
+ * Re-parent URL-bearing siblings by URL path so descendants nest under their
2320
+ * URL prefix even when no page exists at every intermediate segment. We build
2321
+ * a trie from each page's URL path and render it back out as a page tree:
2322
+ *
2323
+ * - The shared top-level prefix is elided (the category folder is its
2324
+ * implicit container — e.g. /docs/ai/evals collapses into "Pydantic Evals").
2325
+ * - Below the elision point, every segment becomes an entry: a real page
2326
+ * where one exists, otherwise an empty-parent stub (folder-icon, no URL).
2327
+ *
2328
+ * `anchorSegs` is the parent page's URL segments during recursion; when set,
2329
+ * we walk the trie to that exact depth instead of eliding to the natural
2330
+ * branch point, so a missing intermediate like `b` in `/a → /a/b/c, /a/b/d`
2331
+ * still renders as an empty parent under `/a`.
1625
2332
  *
1626
2333
  * @example
1627
2334
  * nestByUrlHierarchy([
1628
- * { title: 'CLI', url: 'https://x.com/getting-started/cli' },
1629
- * { title: 'Overview', url: 'https://x.com/getting-started/cli/overview' },
2335
+ * { title: 'Hello', url: 'https://x.com/main/sub1/sub2/sub3/hello' },
2336
+ * { title: 'World', url: 'https://x.com/main/sub1/sub2/sub3/world' },
2337
+ * { title: 'Sub 1', url: 'https://x.com/main/sub1' },
1630
2338
  * ])
1631
2339
  * // [
1632
- * // { title: 'CLI', url: '…/cli', pages: [
1633
- * // { title: 'Overview', url: '…/cli/overview' },
2340
+ * // { title: 'Sub 1', url: '…/main/sub1', pages: [
2341
+ * // { title: 'Sub2', _emptyParent: true, pages: [
2342
+ * // { title: 'Sub3', _emptyParent: true, pages: [
2343
+ * // { title: 'Hello', url: '…/main/sub1/sub2/sub3/hello' },
2344
+ * // { title: 'World', url: '…/main/sub1/sub2/sub3/world' },
2345
+ * // ]},
2346
+ * // ]},
1634
2347
  * // ]},
1635
2348
  * // ]
1636
2349
  */
1637
- function nestByUrlHierarchy(pages) {
2350
+ function nestByUrlHierarchy(pages, anchorSegs = null) {
1638
2351
  if (!pages || pages.length === 0) return pages
1639
2352
 
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()
2353
+ // Recurse first so subtrees that came in pre-nested (e.g. from the scraper)
2354
+ // get re-nested against their parent's URL as anchor.
1647
2355
  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)
2356
+ if (p.pages && p.pages.length > 0) {
2357
+ let nextAnchor = anchorSegs
2358
+ if (p._virtualPathSegs) nextAnchor = p._virtualPathSegs
2359
+ else if (p.url) {
2360
+ const s = urlTrieSegs(p.url)
2361
+ if (s) nextAnchor = s
2362
+ }
2363
+ p.pages = nestByUrlHierarchy(p.pages, nextAnchor)
2364
+ }
1651
2365
  }
1652
- if (byPath.size < 2) return pages
1653
2366
 
1654
- const result = []
2367
+ const urlEntries = []
2368
+ const groupOnly = []
1655
2369
  for (const p of pages) {
1656
2370
  if (!p.url) {
1657
- result.push(p)
2371
+ groupOnly.push(p)
1658
2372
  continue
1659
2373
  }
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
2374
+ const segs = urlTrieSegs(p.url)
2375
+ // Zero-segment URLs (origin-only links like `https://build.example.com/`)
2376
+ // have no path to nest by — if we inserted them into the trie they'd land
2377
+ // at the root node and the container walk would render them as the
2378
+ // top-level entry with every other page nested beneath them. Treat them
2379
+ // like group-only pages so they just sit alongside the real tree.
2380
+ if (segs && segs.length > 0) urlEntries.push({ page: p, segs })
2381
+ else groupOnly.push(p)
2382
+ }
2383
+
2384
+ if (urlEntries.length === 0) return pages
2385
+ if (urlEntries.length === 1 && groupOnly.length === 0) return pages
2386
+
2387
+ // Build the URL trie. Each node tracks its own segment, the page at that
2388
+ // exact path (if any), insertion-ordered children, and the segment path
2389
+ // from root (used as the slug source for empty parents).
2390
+ const root = { segment: '', page: null, children: new Map(), segs: [] }
2391
+ for (const { page, segs } of urlEntries) {
2392
+ let node = root
2393
+ for (let i = 0; i < segs.length; i++) {
2394
+ const segLower = segs[i].toLowerCase()
2395
+ if (!node.children.has(segLower)) {
2396
+ node.children.set(segLower, { segment: segs[i], page: null, children: new Map(), segs: segs.slice(0, i + 1) })
1677
2397
  }
2398
+ node = node.children.get(segLower)
1678
2399
  }
1679
- if (parent) {
1680
- if (!parent.pages) parent.pages = []
1681
- parent.pages.push(p)
2400
+ if (!node.page) node.page = page
2401
+ }
2402
+
2403
+ // Find the trie node that represents the "container" for this level.
2404
+ // - With an anchor: that's the parent page's exact depth; we don't elide
2405
+ // past it, so intermediate segments below the anchor become empties.
2406
+ // - Without: walk down while there's only one no-page child (the shared
2407
+ // prefix). Stop at the first node that either has a page or branches.
2408
+ let container = root
2409
+ if (anchorSegs && anchorSegs.length > 0) {
2410
+ for (let i = 0; i < anchorSegs.length; i++) {
2411
+ const seg = anchorSegs[i].toLowerCase()
2412
+ if (container.children.has(seg)) container = container.children.get(seg)
2413
+ else break
2414
+ }
2415
+ } else {
2416
+ while (container.children.size === 1 && !container.page) {
2417
+ container = container.children.values().next().value
2418
+ }
2419
+ }
2420
+
2421
+ function nodeToPage(node) {
2422
+ let outPage
2423
+ if (node.page) {
2424
+ outPage = node.page
1682
2425
  } else {
1683
- result.push(p)
2426
+ const rawSeg = node.segment
2427
+ const cleanedSeg = stripSegmentExtensions(rawSeg).replace(/^\d+[-_.]/, '') || rawSeg
2428
+ outPage = {
2429
+ title: titleCase(cleanedSeg),
2430
+ _emptyParent: true,
2431
+ _virtualPathSegs: node.segs,
2432
+ pages: [],
2433
+ }
2434
+ }
2435
+ if (node.children.size > 0) {
2436
+ const childPages = []
2437
+ for (const c of node.children.values()) childPages.push(nodeToPage(c))
2438
+ // Preserve any pre-existing nested children whose URLs aren't already
2439
+ // represented in the trie (group-only containers from the scraper, etc.).
2440
+ const existing = outPage.pages || []
2441
+ const trieUrls = new Set()
2442
+ const collect = (p) => {
2443
+ if (p.url) trieUrls.add(normalizePath(p.url))
2444
+ for (const c of p.pages || []) collect(c)
2445
+ }
2446
+ for (const cp of childPages) collect(cp)
2447
+ const preservedExisting = existing.filter((e) => !e.url || !trieUrls.has(normalizePath(e.url)))
2448
+ outPage.pages = [...childPages, ...preservedExisting]
1684
2449
  }
2450
+ return outPage
1685
2451
  }
1686
- return result
2452
+
2453
+ const trieResult = []
2454
+ if (container.page) {
2455
+ trieResult.push(nodeToPage(container))
2456
+ } else {
2457
+ for (const c of container.children.values()) trieResult.push(nodeToPage(c))
2458
+ }
2459
+
2460
+ return [...trieResult, ...groupOnly]
1687
2461
  }
1688
2462
 
1689
2463
  /**
@@ -1800,6 +2574,62 @@ async function fetchHtmlDirect(url) {
1800
2574
  }
1801
2575
  }
1802
2576
 
2577
+ /**
2578
+ * Download a `.json` URL as raw bytes. Returns a Buffer, or null on non-OK /
2579
+ * network error. No parsing or validation — the runner is the OAS gate.
2580
+ */
2581
+ async function fetchJsonSpec(url) {
2582
+ try {
2583
+ const res = await fetch(url, {
2584
+ redirect: 'follow',
2585
+ headers: { 'User-Agent': 'readme-cli-import', Accept: 'application/json' },
2586
+ })
2587
+ if (!res.ok) return null
2588
+ return Buffer.from(await res.arrayBuffer())
2589
+ } catch {
2590
+ return null
2591
+ }
2592
+ }
2593
+
2594
+ /**
2595
+ * Download the captured candidate-spec URLs into `<stagingDir>/oas/<name>.json`.
2596
+ * Filenames come from the URL basename (collisions get a numeric suffix); the
2597
+ * runner de-dups by content, so identical bytes under different names are
2598
+ * handled there. Returns the count of files written.
2599
+ */
2600
+ async function downloadOasSpecs(oasJsonUrls, stagingDir) {
2601
+ const oasDir = path.join(stagingDir, 'oas')
2602
+ fs.mkdirSync(oasDir, { recursive: true })
2603
+ const usedNames = new Set()
2604
+ let written = 0
2605
+ for (const { url } of oasJsonUrls) {
2606
+ const buf = await fetchJsonSpec(url)
2607
+ if (!buf) {
2608
+ styles.info(styles.dim(` oas: skipped ${url} (download failed)`))
2609
+ continue
2610
+ }
2611
+ // Derive a safe, unique *.json filename from the URL basename.
2612
+ let base
2613
+ try {
2614
+ base = path.basename(new URL(url).pathname) || 'openapi.json'
2615
+ } catch {
2616
+ base = 'openapi.json'
2617
+ }
2618
+ base = base.replace(/[^a-zA-Z0-9._-]/g, '-')
2619
+ if (!/\.json$/i.test(base)) base += '.json'
2620
+ let name = base
2621
+ let n = 2
2622
+ while (usedNames.has(name)) {
2623
+ name = base.replace(/\.json$/i, `-${n}.json`)
2624
+ n++
2625
+ }
2626
+ usedNames.add(name)
2627
+ fs.writeFileSync(path.join(oasDir, name), buf)
2628
+ written++
2629
+ }
2630
+ return written
2631
+ }
2632
+
1803
2633
  /**
1804
2634
  * Firecrawl-backed HTML loader. Firecrawl runs a real browser, waits for
1805
2635
  * hydration, and returns the rendered DOM — which is what we need for sites
@@ -1900,14 +2730,29 @@ function urlNamespace(url) {
1900
2730
  function clusterByUrlPath(pages) {
1901
2731
  if (!pages || pages.length < 3) return null
1902
2732
 
1903
- const parts = pages.map((p) => {
2733
+ // Pages with no path segments (e.g. an origin-only URL like
2734
+ // `https://build.example.com/`) can't participate in segment-based
2735
+ // clustering. A handful are fine — we'll fold them into the first cluster
2736
+ // as a place to land — but if more than 20% of the input is rootless,
2737
+ // the source isn't really a hierarchical doc set and clustering won't
2738
+ // produce a meaningful tree. Bail in that case so the caller falls back.
2739
+ const noSegPages = []
2740
+ const clusterable = []
2741
+ const parts = []
2742
+ for (const p of pages) {
2743
+ let segs = []
1904
2744
  try {
1905
- return new URL(p.url).pathname.split('/').filter(Boolean)
1906
- } catch {
1907
- return []
2745
+ segs = new URL(p.url).pathname.split('/').filter(Boolean)
2746
+ } catch { }
2747
+ if (segs.length === 0) {
2748
+ noSegPages.push(p)
2749
+ } else {
2750
+ clusterable.push(p)
2751
+ parts.push(segs)
1908
2752
  }
1909
- })
1910
- if (parts.some((pp) => pp.length === 0)) return null
2753
+ }
2754
+ if (noSegPages.length / pages.length > 0.2) return null
2755
+ if (clusterable.length < 3) return null
1911
2756
 
1912
2757
  // Longest common prefix depth.
1913
2758
  let commonDepth = 0
@@ -1920,13 +2765,13 @@ function clusterByUrlPath(pages) {
1920
2765
  // The segment right after the common base is the category key.
1921
2766
  const keyIdx = commonDepth
1922
2767
  const byKey = new Map()
1923
- for (let i = 0; i < pages.length; i++) {
2768
+ for (let i = 0; i < clusterable.length; i++) {
1924
2769
  const key = parts[i][keyIdx]
1925
2770
  // Skip pages that have no segment at the cluster index (they're AT the
1926
2771
  // common base — those would become their own "index"-like category).
1927
2772
  if (!key) continue
1928
2773
  if (!byKey.has(key)) byKey.set(key, [])
1929
- byKey.get(key).push(pages[i])
2774
+ byKey.get(key).push(clusterable[i])
1930
2775
  }
1931
2776
 
1932
2777
  // Reject weak clusterings: need at least 2 groups AND at least one group
@@ -1937,7 +2782,7 @@ function clusterByUrlPath(pages) {
1937
2782
 
1938
2783
  // Preserve first-appearance order so the sidebar reflects source order.
1939
2784
  const firstSeen = new Map()
1940
- pages.forEach((p, i) => {
2785
+ clusterable.forEach((p, i) => {
1941
2786
  const key = parts[i][keyIdx]
1942
2787
  if (key && !firstSeen.has(key)) firstSeen.set(key, i)
1943
2788
  })
@@ -1948,28 +2793,173 @@ function clusterByUrlPath(pages) {
1948
2793
  pages: byKey.get(key),
1949
2794
  }))
1950
2795
 
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 })
2796
+ // Classify each cluster as either a real category or an "Other
2797
+ // Documentation" misfit. A real category needs at least 2 pages — a
2798
+ // "category" with a single file in it just adds a level of nesting
2799
+ // without adding structure. Pydantic's `/docs/logfire/get-started` is
2800
+ // the motivating example: as the only `/docs/logfire/*` URL it
2801
+ // shouldn't get its own top-level "Logfire" folder. Lone pages land in
2802
+ // Other Documentation where they sit alongside other low-volume material.
2803
+ const realCategories = []
2804
+ const misfitPages = []
2805
+ for (const cluster of rawClusters) {
2806
+ if (cluster.pages.length >= 2) {
2807
+ realCategories.push(cluster)
2808
+ continue
2809
+ }
2810
+ misfitPages.push(cluster.pages[0])
1963
2811
  }
1964
- out.push(...multipageClusters)
1965
2812
 
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
2813
+ // If clustering produced no real categories, it added no value — tell the
2814
+ // caller to fall back rather than hand back a lone misfit bucket.
2815
+ if (realCategories.length === 0) return null
2816
+
2817
+ const out = [...realCategories]
2818
+
2819
+ // "Other Documentation" goes last so it never displaces a real category.
2820
+ // It absorbs both standalone-page singletons and zero-segment URLs (those
2821
+ // can't participate in segment-based clustering at all but the >20% guard
2822
+ // above already proved they're a minority).
2823
+ const otherDocs = [...misfitPages, ...noSegPages]
2824
+ if (otherDocs.length > 0) {
2825
+ out.push({ title: 'Other Documentation', pages: otherDocs })
2826
+ }
1970
2827
  return out
1971
2828
  }
1972
2829
 
2830
+ /**
2831
+ * Import section landing pages that the source models structurally rather than
2832
+ * as a link. In llms.txt a section like the Vercel CLI is a bare `## CLI`
2833
+ * heading followed by 68 `/docs/cli/<cmd>` links — the importer turns the
2834
+ * heading into a category but `/docs/cli` itself is never listed, so its real,
2835
+ * content-bearing overview page is dropped. ReadMe then redirects the
2836
+ * content-less category to its first child (a broken landing page).
2837
+ *
2838
+ * For each category we compute the common URL prefix of its children (the
2839
+ * section's landing URL), HTTP-probe it, and — when it really exists and stays
2840
+ * in scope — prepend it as a real page so nestByUrlHierarchy renders it as the
2841
+ * parent holding the children. Mutates `organized` in place.
2842
+ */
2843
+ async function injectSectionLandingPages(organized, sourceUrl) {
2844
+ const cats = organized?.categories || []
2845
+ if (cats.length === 0) return
2846
+
2847
+ // Dedup set: every page URL already in the tree, by normalized path.
2848
+ const known = new Set()
2849
+ for (const c of cats) {
2850
+ for (const p of collectUrlPagesDeep(c.pages || [])) known.add(normalizePath(p.url))
2851
+ }
2852
+
2853
+ // Base path of the import itself — landing candidates must be strictly
2854
+ // deeper than this so we never probe `/docs` (the base) as a "section".
2855
+ const baseDepth = (urlTrieSegs(sourceUrl) || []).length
2856
+ const origin = new URL(sourceUrl).origin
2857
+
2858
+ // Build one landing candidate per eligible category (cheap gate first).
2859
+ const candidates = []
2860
+ for (const cat of cats) {
2861
+ const kids = collectUrlPagesDeep(cat.pages || [])
2862
+ if (kids.length < 2) continue
2863
+
2864
+ // Longest common path prefix across the children — same loop as
2865
+ // clusterByUrlPath's common-depth scan, over urlTrieSegs.
2866
+ const parts = kids.map((p) => urlTrieSegs(p.url)).filter(Boolean)
2867
+ if (parts.length < 2) continue
2868
+ let commonDepth = 0
2869
+ while (commonDepth < parts[0].length) {
2870
+ const seg = parts[0][commonDepth]
2871
+ if (!parts.every((pp) => pp[commonDepth] === seg)) break
2872
+ commonDepth++
2873
+ }
2874
+ const commonSegs = parts[0].slice(0, commonDepth)
2875
+
2876
+ // Must be strictly deeper than the import base, and not already a page.
2877
+ if (commonSegs.length <= baseDepth) continue
2878
+ const landingUrl = `${origin}/${commonSegs.join('/')}`
2879
+ if (known.has(normalizePath(landingUrl))) continue
2880
+
2881
+ candidates.push({ cat, landingUrl })
2882
+ }
2883
+ if (candidates.length === 0) return
2884
+
2885
+ // Probe surviving candidates in parallel. Accept only a real, in-scope page
2886
+ // that doesn't redirect onto an already-known child.
2887
+ const accepted = []
2888
+ await visitAllInParallel(
2889
+ candidates,
2890
+ async ({ cat, landingUrl }) => {
2891
+ try {
2892
+ const res = await fetch(landingUrl, {
2893
+ redirect: 'follow',
2894
+ headers: { 'User-Agent': 'readme-cli-import' },
2895
+ })
2896
+ if (!res.ok) return
2897
+ const candPath = new URL(landingUrl).pathname.toLowerCase()
2898
+ const finalPath = new URL(res.url).pathname.toLowerCase()
2899
+ if (!finalPath.startsWith(candPath)) return
2900
+ if (known.has(normalizePath(res.url))) return
2901
+
2902
+ // Check `<meta http-equiv="refresh">` navigates the browser elsewhere.
2903
+ const html = await res.text()
2904
+ const metaRefresh = parseMetaRefreshTarget(html, res.url)
2905
+ if (metaRefresh && normalizePath(metaRefresh) !== normalizePath(res.url)) return
2906
+ const canonical = parseHtmlCanonical(html, res.url)
2907
+ if (canonical && known.has(normalizePath(canonical))) return
2908
+
2909
+ accepted.push({ cat, landingUrl })
2910
+ } catch {
2911
+ // Network error / unparseable — treat as "no landing page".
2912
+ }
2913
+ },
2914
+ LANDING_PROBE_CAP,
2915
+ )
2916
+
2917
+ for (const { cat, landingUrl } of accepted) {
2918
+ // Don't set x-from-llms: the page isn't in llmsPaths, so the runner fetches
2919
+ // its rendered HTML via the content cascade (there's no .md mirror).
2920
+ cat.pages.unshift({ title: cat.title, url: landingUrl })
2921
+ known.add(normalizePath(landingUrl))
2922
+ }
2923
+ if (accepted.length > 0) {
2924
+ styles.info(`Imported ${styles.bold(String(accepted.length))} section landing page(s).`)
2925
+ }
2926
+ }
2927
+
2928
+ /**
2929
+ * Parse a client-side redirect target from an HTML page. Return pages with
2930
+ * `<meta http-equiv="refresh" content="0;url=/target/">` that navigates
2931
+ * the browser elsewhere.
2932
+ */
2933
+ function parseMetaRefreshTarget(html, baseUrl) {
2934
+ if (!html) return null
2935
+ const tag = /<meta[^>]+http-equiv=["']?refresh["']?[^>]*>/i.exec(html)
2936
+ if (!tag) return null
2937
+ const m = /content=["'][^"']*url=([^"'>\s]+)/i.exec(tag[0])
2938
+ if (!m) return null
2939
+ try {
2940
+ return new URL(decodeEntities(m[1].trim()), baseUrl).toString()
2941
+ } catch {
2942
+ return null
2943
+ }
2944
+ }
2945
+
2946
+ /**
2947
+ * Parse the `<link rel="canonical" href="...">` target from an HTML page,
2948
+ * resolved absolute against `baseUrl`. Returns null when absent or unparseable.
2949
+ */
2950
+ function parseHtmlCanonical(html, baseUrl) {
2951
+ if (!html) return null
2952
+ const tag = /<link[^>]+rel=["']?canonical["']?[^>]*>/i.exec(html)
2953
+ if (!tag) return null
2954
+ const m = /href=["']([^"']+)["']/i.exec(tag[0])
2955
+ if (!m) return null
2956
+ try {
2957
+ return new URL(decodeEntities(m[1].trim()), baseUrl).toString()
2958
+ } catch {
2959
+ return null
2960
+ }
2961
+ }
2962
+
1973
2963
  /**
1974
2964
  * Used by discovery-mode scraping (no llms.txt) to decide whether a nav
1975
2965
  * link is worth importing as a doc page. Filters out cross-origin links,
@@ -1990,6 +2980,20 @@ function isDiscoverableLink(abs, base) {
1990
2980
  return true
1991
2981
  }
1992
2982
 
2983
+ // Leading-segment auth routes: /auth, /login, /sign-in, /logout, /sso, /oauth…
2984
+ // These are login/account chrome (Stoplight's "Sign in" → /auth, etc.), not
2985
+ // docs. NOT filtered at link-discovery time — on some Stoplight sites the
2986
+ // bare landing page only exposes the "Sign in" link, which we still need as a
2987
+ // crawl seed to reach the real sidebar. Pruned from the tree post-crawl.
2988
+ const AUTH_ROUTE_RE = /^\/(?:auth|login|log-?in|sign-?in|sign-?up|signup|logout|log-?out|sign-?out|signout|register|sso|oauth2?)(?:\/|$)/i
2989
+ function isAuthChromeUrl(url) {
2990
+ try {
2991
+ return AUTH_ROUTE_RE.test(new URL(url).pathname)
2992
+ } catch {
2993
+ return false
2994
+ }
2995
+ }
2996
+
1993
2997
  // Zero-width spaces, direction marks, word joiner, BOM — some docs sites
1994
2998
  // inject these into sidebar headings (docs.greenflash.ai prefixes
1995
2999
  // "API Reference" with U+200B), which otherwise defeats downstream
@@ -2029,20 +3033,7 @@ function decodeEntities(s) {
2029
3033
  .replace(/&rdquo;/g, '”')
2030
3034
  }
2031
3035
 
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
- }
3036
+ // normalizePath is imported from ../utils/url-segs.js
2046
3037
 
2047
3038
  /**
2048
3039
  * Given a set of scraped categories + orphan pages that didn't match the nav
@@ -2056,7 +3047,8 @@ async function slotOrphansWithClaude(scraped, orphans, model) {
2056
3047
  categories: scraped.categories,
2057
3048
  orphans,
2058
3049
  })
2059
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3050
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: slotOrphansOutputSchema })
3051
+ const raw = result.assignments
2060
3052
  if (!Array.isArray(raw)) {
2061
3053
  // If the model didn't cooperate, return all orphans unassigned.
2062
3054
  return orphans
@@ -2086,8 +3078,8 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
2086
3078
  const { systemPrompt, userPrompt } = iconizeNavPrompt({
2087
3079
  categories: scraped.categories,
2088
3080
  })
2089
- const icons = await runJsonQuery({ systemPrompt, userPrompt, model })
2090
- const iconArr = Array.isArray(icons) ? icons : []
3081
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: iconizeNavOutputSchema })
3082
+ const iconArr = Array.isArray(result.icons) ? result.icons : []
2091
3083
  return {
2092
3084
  title: siteTitle || null,
2093
3085
  categories: scraped.categories.map((c, i) => ({
@@ -2099,31 +3091,22 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
2099
3091
  }
2100
3092
 
2101
3093
  /**
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).
3094
+ * Return the subset of llms.txt sections worth sending to the fast path —
3095
+ * drop only structurally bad ones (empty, or oversized site-dumps). Heading
3096
+ * names are preserved verbatim: a heading like "Docs" or "Documentation"
3097
+ * might be a catch-all on one site (bad) and a real top-level category on
3098
+ * another (fine), and we can't tell cheaply, so we don't try.
2118
3099
  */
2119
3100
  function usableSections(sections) {
2120
3101
  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)
3102
+ return sections.filter((s) => s.title && s.items && s.items.length > 0 && s.items.length <= 200)
2122
3103
  }
2123
3104
 
2124
3105
  function sectionsLookUsable(sections) {
2125
- if (!sections || sections.length > 40) return false
2126
- return usableSections(sections).length >= 3
3106
+ const usable = usableSections(sections)
3107
+ if (usable.length < 3) return false
3108
+ const avg = usable.reduce((n, s) => n + s.items.length, 0) / usable.length
3109
+ return avg >= 5 && avg <= 100
2127
3110
  }
2128
3111
 
2129
3112
  async function organizeWithClaude(parsed, model) {
@@ -2139,16 +3122,17 @@ async function organizeWithClaude(parsed, model) {
2139
3122
  * is O(sections), not O(pages), so this is usually ~5-15s vs. a full reorg.
2140
3123
  */
2141
3124
  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).
3125
+ // Drop empty/oversized sections (e.g. a 0-item "Instructions for Large
3126
+ // Language Model Agents" preamble, or a 500-row site-dump) — heading
3127
+ // names are preserved as-is.
2145
3128
  const sections = usableSections(parsed.sections)
2146
3129
 
2147
3130
  const { systemPrompt, userPrompt } = organizeFromSectionsPrompt({
2148
3131
  siteTitle: parsed.title,
2149
3132
  sections,
2150
3133
  })
2151
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3134
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromSectionsOutputSchema })
3135
+ const raw = result.sections
2152
3136
  if (!Array.isArray(raw)) {
2153
3137
  throw new Error('Fast-path expected a JSON array of {title, icon} entries.')
2154
3138
  }
@@ -2183,18 +3167,22 @@ async function organizeFromScratch(parsed, model) {
2183
3167
  siteTitle: parsed.title,
2184
3168
  items,
2185
3169
  })
2186
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3170
+ const raw = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromScratchOutputSchema })
2187
3171
 
2188
3172
  // Rehydrate pages from the id references Claude returned.
2189
3173
  const expandedCategories = []
2190
3174
  const usedIds = new Set()
3175
+ const usedUrls = new Set()
2191
3176
  for (const cat of raw.categories || []) {
2192
3177
  const pages = []
2193
3178
  for (const id of cat.pageIds || []) {
2194
3179
  const item = items[id]
2195
3180
  if (!item) continue // ignore out-of-range ids
2196
3181
  if (usedIds.has(id)) continue // ignore dupes
3182
+ const normUrl = normalizePath(item.url)
3183
+ if (usedUrls.has(normUrl)) continue // guard against same URL at different input indexes
2197
3184
  usedIds.add(id)
3185
+ usedUrls.add(normUrl)
2198
3186
  pages.push({
2199
3187
  title: item.title,
2200
3188
  url: item.url,
@@ -2224,11 +3212,13 @@ async function organizeFromScratch(parsed, model) {
2224
3212
  }
2225
3213
 
2226
3214
  /**
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.
3215
+ * Shared Claude call for "send a prompt, get schema-validated JSON back".
3216
+ * Logs the prompts so we can see what went in, and runs a heartbeat so silent
3217
+ * model latency doesn't look like a hang. The SDK enforces `schema` and
3218
+ * returns parsed data on the result message's `structured_output` field, so
3219
+ * there's no text extraction or JSON.parse on this path.
2230
3220
  */
2231
- async function runJsonQuery({ systemPrompt, userPrompt, model }) {
3221
+ async function runJsonQuery({ systemPrompt, userPrompt, model, schema }) {
2232
3222
  console.log()
2233
3223
  console.log(styles.dim('─ system prompt ─'))
2234
3224
  console.log(styles.dim(systemPrompt))
@@ -2242,24 +3232,28 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2242
3232
  console.log()
2243
3233
 
2244
3234
  const heartbeat = setInterval(() => process.stdout.write(styles.dim('.')), 1000)
2245
- let text = ''
3235
+ let structured
2246
3236
  try {
2247
3237
  for await (const message of query({
2248
3238
  prompt: userPrompt,
2249
3239
  options: {
2250
3240
  systemPrompt,
2251
3241
  allowedTools: [],
3242
+ outputFormat: { type: 'json_schema', schema },
2252
3243
  ...(model ? { model } : {}),
2253
3244
  },
2254
3245
  })) {
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
3246
+ if (message.type === 'result') {
3247
+ if (message.subtype === 'error_max_structured_output_retries') {
3248
+ throw new Error(
3249
+ 'Claude could not produce output matching the schema after retries. ' +
3250
+ 'Likely hit the model\'s output limit — try --model sonnet.',
3251
+ )
2258
3252
  }
2259
- } else if (message.type === 'result') {
2260
3253
  if (message.subtype && message.subtype !== 'success') {
2261
3254
  throw new Error(`Claude failed: ${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
2262
3255
  }
3256
+ structured = message.structured_output
2263
3257
  break
2264
3258
  }
2265
3259
  }
@@ -2268,18 +3262,10 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2268
3262
  process.stdout.write('\n')
2269
3263
  }
2270
3264
 
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
- )
3265
+ if (!structured || typeof structured !== 'object') {
3266
+ throw new Error('Claude returned no structured output')
2282
3267
  }
3268
+ return structured
2283
3269
  }
2284
3270
 
2285
3271
  /**
@@ -2294,85 +3280,614 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2294
3280
  *
2295
3281
  * Returns deduped URLs in probe order.
2296
3282
  */
2297
- function buildLlmsCandidates(sourceUrl) {
2298
- const out = []
2299
- const seen = new Set()
2300
- const add = (url) => {
2301
- if (!seen.has(url)) {
2302
- seen.add(url)
2303
- out.push(url)
2304
- }
3283
+ // URL patterns we strip from llms.txt items — they show up alongside real
3284
+ // pages but shouldn't be staged as docs:
3285
+ // - .yaml specs, package.json-ish assets, sitemaps, etc. (machine-readable)
3286
+ // - llms.txt, llms-full.txt, llms-ctx.txt, … (other llms.txt variants
3287
+ // that point back at the same index/dump, not real pages)
3288
+ //
3289
+ // `.json` is deliberately NOT in this list: those links are captured up-front
3290
+ // by extractOasJsonUrlsFromParsed (they may be OpenAPI specs we want to
3291
+ // download into oas/ and upload separately) and removed from the parsed tree
3292
+ // before this drop runs, so a `.json` item never reaches here.
3293
+ const ASSET_EXT_RE = /\.(ya?ml|xml|toml)$/i
3294
+ const LLMS_TXT_RE = /(?:^|\/)llms[^/]*\.txt$/i
3295
+ function isAssetOrMetaUrl(url) {
3296
+ try {
3297
+ const pn = new URL(url).pathname
3298
+ return ASSET_EXT_RE.test(pn) || LLMS_TXT_RE.test(pn)
3299
+ } catch {
3300
+ return false
2305
3301
  }
3302
+ }
2306
3303
 
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`)
3304
+ // A llms.txt item whose URL points at a `.json` file. These are pulled out of
3305
+ // the parsed tree before organization (so stub generation never sees them) and
3306
+ // downloaded into the staged `oas/` dir; the runner validates each as OpenAPI
3307
+ // and uploads the valid ones. NOTE: only `.json` is captured — `.yaml`/`.yml`
3308
+ // specs are intentionally left to the asset drop above.
3309
+ function isOasJsonUrl(url) {
3310
+ try {
3311
+ return /\.json$/i.test(new URL(url).pathname)
3312
+ } catch {
3313
+ return false
2312
3314
  }
2313
- return out
3315
+ }
3316
+
3317
+ // Immediate-child segments of root that signal a docs subtree on a
3318
+ // non-docs hostname. We deliberately keep the set tiny — over-matching
3319
+ // here (e.g. including `api`, `learn`) would over-filter sites whose
3320
+ // "docs" subtree lives at the apex.
3321
+ const DOCS_LIKE_ROOT_SEGMENTS = new Set(['docs', 'doc', 'documentation', 'document'])
3322
+
3323
+ /**
3324
+ * Narrow a merged llms.txt result to a docs subtree when a root-level
3325
+ * llms.txt pulled non-doc URLs (marketing, pricing, blog, etc.) into the
3326
+ * union.
3327
+ *
3328
+ * Sites that publish a root llms.txt alongside per-product subtree files
3329
+ * often mix marketing/pricing/blog links into the root file. The BFS merge
3330
+ * happily unions all of it, and without this pass we'd stage stubs for
3331
+ * those pages too.
3332
+ *
3333
+ * elevenlabs.io is the canonical example: its root `elevenlabs.io/llms.txt`
3334
+ * lists marketing pages alongside docs, and per-product files live at
3335
+ * `elevenlabs.io/docs/<product>/llms.txt`. After the BFS we have docs URLs
3336
+ * + pricing/blog URLs in the same merged result. With this pass we keep
3337
+ * only URLs under `/docs/`.
3338
+ *
3339
+ * Conditions (all must hold), chosen so we only narrow when the risk is
3340
+ * real and there's a clear scope to narrow to:
3341
+ * - Hostname doesn't itself contain "docs" — `docs.example.com` is
3342
+ * already a docs-only host; nothing to narrow.
3343
+ * - A root-level llms.txt was actually probed and hit (valid OR
3344
+ * invalid). Either way it's what dragged in the wider URL space via
3345
+ * the BFS expansion, so it's the trigger we care about.
3346
+ * - Multiple llms.txt files contributed to the merge. A single-file
3347
+ * merge is by definition scoped to that one file's intent.
3348
+ * - The merged URL set has a docs-shaped first-segment child of root
3349
+ * (`/docs/`, `/documentation/`, …). That child is our scope target.
3350
+ *
3351
+ * Mutates `llms.parsed.sections` in place. Returns `{ segment, kept,
3352
+ * dropped }` when narrowing happened, or `null` when conditions weren't
3353
+ * met.
3354
+ */
3355
+ function narrowToDocsSubtreeIfNeeded(llms, sourceUrl, hits) {
3356
+ if (!llms || llms.sourceFiles.length < 2) return null
3357
+ if (sourceUrl.hostname.toLowerCase().includes('docs')) return null
3358
+ const rootHit = hits.some((h) => h.path === '/' || h.path === '')
3359
+ if (!rootHit) return null
3360
+
3361
+ // Identify docs-shaped first segments of root that actually appear in
3362
+ // the merged URLs. Walking just the first path segment matches the
3363
+ // user's spec ("we only look at immediate children in this case").
3364
+ const firstSegs = new Set()
3365
+ for (const section of llms.parsed.sections) {
3366
+ for (const item of section.items) {
3367
+ try {
3368
+ const seg = new URL(item.url).pathname.split('/').filter(Boolean)[0]
3369
+ if (seg) firstSegs.add(seg.toLowerCase())
3370
+ } catch {
3371
+ // ignore unparseable URLs
3372
+ }
3373
+ }
3374
+ }
3375
+ let docsSegment = null
3376
+ for (const seg of firstSegs) {
3377
+ if (DOCS_LIKE_ROOT_SEGMENTS.has(seg)) {
3378
+ docsSegment = seg
3379
+ break
3380
+ }
3381
+ }
3382
+ if (!docsSegment) return null
3383
+
3384
+ const prefix = '/' + docsSegment
3385
+ let kept = 0
3386
+ let dropped = 0
3387
+ const keptSections = []
3388
+ for (const section of llms.parsed.sections) {
3389
+ const items = []
3390
+ for (const item of section.items) {
3391
+ try {
3392
+ const pn = new URL(item.url).pathname.toLowerCase()
3393
+ if (pn === prefix || pn.startsWith(prefix + '/')) {
3394
+ items.push(item)
3395
+ kept++
3396
+ } else {
3397
+ dropped++
3398
+ }
3399
+ } catch {
3400
+ dropped++
3401
+ }
3402
+ }
3403
+ if (items.length > 0) {
3404
+ section.items = items
3405
+ keptSections.push(section)
3406
+ }
3407
+ }
3408
+ llms.parsed.sections = keptSections
3409
+ return { segment: docsSegment, kept, dropped }
2314
3410
  }
2315
3411
 
2316
3412
  /**
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 }] }] }.
3413
+ * Pull `.json` items out of a parsed llms.txt structure IN PLACE, returning the
3414
+ * removed items as `[{ url, text }]`. Sections emptied by the removal are
3415
+ * pruned too — same shape as dropAssetItemsFromParsed. Run BEFORE
3416
+ * dropAssetItemsFromParsed / knownUrls / organize so the captured specs never
3417
+ * enter the stub tree (skeleton has zero visibility of OAS files). The caller
3418
+ * downloads the returned URLs into the staged `oas/` dir.
2319
3419
  */
2320
- async function fetchLlmsTxt(llmsUrl) {
3420
+ function extractOasJsonUrlsFromParsed(parsed) {
3421
+ const captured = []
3422
+ const keptSections = []
3423
+ for (const section of parsed.sections) {
3424
+ const keptItems = []
3425
+ for (const item of section.items) {
3426
+ if (isOasJsonUrl(item.url)) {
3427
+ captured.push({ url: item.url, text: item.text || null })
3428
+ continue
3429
+ }
3430
+ keptItems.push(item)
3431
+ }
3432
+ if (keptItems.length > 0) {
3433
+ section.items = keptItems
3434
+ keptSections.push(section)
3435
+ }
3436
+ }
3437
+ parsed.sections = keptSections
3438
+ return captured
3439
+ }
3440
+
3441
+ /**
3442
+ * Drop asset/meta items in place from a parsed llms.txt structure. Sections
3443
+ * that end up empty after the drop are removed too. Returns the count of
3444
+ * items dropped.
3445
+ */
3446
+ function dropAssetItemsFromParsed(parsed) {
3447
+ let dropped = 0
3448
+ const keptSections = []
3449
+ for (const section of parsed.sections) {
3450
+ const keptItems = []
3451
+ for (const item of section.items) {
3452
+ if (isAssetOrMetaUrl(item.url)) {
3453
+ dropped++
3454
+ continue
3455
+ }
3456
+ keptItems.push(item)
3457
+ }
3458
+ if (keptItems.length > 0) {
3459
+ section.items = keptItems
3460
+ keptSections.push(section)
3461
+ }
3462
+ }
3463
+ parsed.sections = keptSections
3464
+ return dropped
3465
+ }
3466
+
3467
+
3468
+ /**
3469
+ * Build the ordered list of well-known docs locations to probe. For each route
3470
+ * we emit a subdomain candidate (stripping a leading `www.` first) and, unless
3471
+ * the route is subdomain-only, a path candidate. Base URLs end in `/` so
3472
+ * `${url.href}llms.txt` is well-formed.
3473
+ */
3474
+ function buildWellKnownDocRoutes(sourceUrl) {
3475
+ const out = []
3476
+ const seen = new Set()
3477
+ const add = (kind, href) => {
3478
+ if (seen.has(href)) return
3479
+ seen.add(href)
3480
+ out.push({ kind, url: new URL(href) })
3481
+ }
3482
+
3483
+ const apexHost = sourceUrl.hostname.replace(/^www\./i, '')
3484
+ for (const route of WELL_KNOWN_DOC_ROUTES) {
3485
+ const subHost = `${route}.${apexHost}`
3486
+ if (subHost !== sourceUrl.hostname) add('subdomain', `${sourceUrl.protocol}//${subHost}/`)
3487
+ if (!SUBDOMAIN_ONLY_DOC_ROUTES.has(route)) add('path', `${sourceUrl.origin}/${route}/`)
3488
+ }
3489
+ return out
3490
+ }
3491
+
3492
+ /**
3493
+ * Follow redirects on the source URL itself and return the final URL when the
3494
+ * site has moved (different origin or path), or null to keep the original.
3495
+ * Trailing-slash and query/hash-only redirects don't count as a move.
3496
+ */
3497
+ async function resolveRedirectedSourceUrl(sourceUrl) {
2321
3498
  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 }
3499
+ const res = await fetch(sourceUrl.href, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
3500
+ if (!res.ok || !res.url) return null
3501
+ const final = new URL(res.url)
3502
+ final.search = ''
3503
+ final.hash = ''
3504
+ const norm = (u) => u.origin + u.pathname.replace(/\/+$/, '')
3505
+ if (norm(final) === norm(sourceUrl)) return null
3506
+ return final
3507
+ } catch {
3508
+ return null
2331
3509
  }
2332
3510
  }
2333
3511
 
2334
3512
  /**
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.
3513
+ * For a bare homepage, probe well-known docs routes and return the best base to
3514
+ * adopt, or null to leave the source untouched. A route serving a usable
3515
+ * llms.txt always beats one that merely resolves. Returns `{ url, kind, hasLlms }`.
2338
3516
  */
2339
- function parseLlmsTxt(body) {
2340
- const lines = body.split(/\r?\n/)
2341
- let title = null
2342
- const sections = []
2343
- let current = null
3517
+ async function resolveDocsBaseUrl(sourceUrl) {
3518
+ if (sourceUrl.pathname && sourceUrl.pathname !== '/') return null
3519
+ if (WELL_KNOWN_DOC_ROUTES.includes(sourceUrl.hostname.split('.')[0].toLowerCase())) return null
3520
+
3521
+ const candidates = buildWellKnownDocRoutes(sourceUrl)
3522
+ if (candidates.length === 0) return null
3523
+
3524
+ styles.info(`Probing ${styles.bold(String(candidates.length))} well-known docs routes from ${styles.bold(sourceUrl.toString())}...`)
3525
+
3526
+ const llmsHits = await Promise.all(candidates.map(async (c) => ({ c, res: await fetchLlmsTxt(`${c.url.href}llms.txt`) })))
3527
+ for (const { c, res } of llmsHits) {
3528
+ const url = `${c.url.href}llms.txt`
3529
+ if (res.ok && res.usable && (res.stats?.linkItems ?? 0) > 0) {
3530
+ if (inCandidateScope(c, res.finalUrl)) {
3531
+ styles.info(styles.dim(` ${url} → valid llms.txt (${res.stats.linkItems} links)`))
3532
+ return { url: c.url, kind: c.kind, hasLlms: true }
3533
+ }
3534
+ styles.info(styles.dim(` ${url} → llms.txt redirects to ${new URL(res.finalUrl).host} (alias) — skipping`))
3535
+ } else if (res.ok) {
3536
+ styles.info(styles.dim(` ${url} → no usable llms.txt`))
3537
+ } else {
3538
+ styles.info(styles.dim(` ${url} → ${res.status ? `HTTP ${res.status}` : res.error || 'no llms.txt'}`))
3539
+ }
3540
+ }
2344
3541
 
2345
- const itemRe = /^\s*-\s*\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)(?:\s*[:—–-]\s*(.+))?/
3542
+ const existence = await Promise.all(
3543
+ llmsHits.map(async ({ c, res }) => {
3544
+ if (res.error && c.kind === 'subdomain') return { c, exists: false }
3545
+ return { c, exists: await docsRouteResolves(c) }
3546
+ }),
3547
+ )
3548
+ for (const { c, exists } of existence) {
3549
+ if (exists) {
3550
+ styles.info(styles.dim(` ${c.url.href} → resolves (no llms.txt)`))
3551
+ return { url: c.url, kind: c.kind, hasLlms: false }
3552
+ }
3553
+ }
2346
3554
 
2347
- for (const line of lines) {
2348
- const h1 = line.match(/^#\s+(.+)$/)
2349
- if (h1 && !title) {
2350
- title = h1[1].trim()
2351
- continue
3555
+ styles.info(styles.dim(` no well-known docs route matched — keeping ${sourceUrl.toString()}`))
3556
+ return null
3557
+ }
3558
+
3559
+ /**
3560
+ * Is `finalUrl` (where a fetch landed after following redirects) still within
3561
+ * the candidate's scope? Subdomain candidates must keep their host; path
3562
+ * candidates must keep their path prefix. A cross-scope redirect means the
3563
+ * route is just an alias to somewhere else (usually the marketing apex).
3564
+ */
3565
+ function inCandidateScope(candidate, finalUrl) {
3566
+ let final
3567
+ try {
3568
+ final = new URL(finalUrl || candidate.url.href)
3569
+ } catch {
3570
+ return false
3571
+ }
3572
+ if (candidate.kind === 'subdomain') return final.hostname === candidate.url.hostname
3573
+ return final.pathname.toLowerCase().startsWith(candidate.url.pathname.toLowerCase())
3574
+ }
3575
+
3576
+ /**
3577
+ * Does a well-known docs route exist and stay in scope? Follows redirects and
3578
+ * rejects anything that lands outside the candidate (a `docs.` subdomain that
3579
+ * bounces to marketing, a `/docs/` that 302s home). DNS failures → false.
3580
+ */
3581
+ async function docsRouteResolves(candidate) {
3582
+ try {
3583
+ const res = await fetch(candidate.url.href, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
3584
+ return res.ok && inCandidateScope(candidate, res.url)
3585
+ } catch {
3586
+ return false
3587
+ }
3588
+ }
3589
+
3590
+ // Hard cap on total probes during BFS discovery — protects against
3591
+ // pathological sites where every URL returns a "200 + a few link rows"
3592
+ // response and the frontier would otherwise blow up.
3593
+ const LLMS_PROBE_CAP = 30
3594
+ // Per-node cap on child segments to expand. Root-level llms.txt files can
3595
+ // list hundreds of URLs; without this a single root hit could schedule
3596
+ // dozens of unrelated probes (`/blog`, `/pricing`, …).
3597
+ const LLMS_CHILDREN_PER_NODE = 12
3598
+ // Budget for the explicit-link phase of discovery (phase 2 in discoverLlmsTxt).
3599
+ // Separate from LLMS_PROBE_CAP so following authored `llms.txt` links — a
3600
+ // strong, deliberate signal, e.g. docs.snowflake.com's root index listing ~40
3601
+ // per-section files — isn't starved by the speculative slug-walk's budget.
3602
+ const EXPLICIT_LLMS_PROBE_CAP = 200
3603
+
3604
+ // Matches ONLY the canonical index filename, not sibling dumps like
3605
+ // `llms-full.txt` (concatenated page content, not a link index) — those would
3606
+ // add no link rows and just waste a fetch.
3607
+ const LLMS_INDEX_RE = /\/llms\.txt$/i
3608
+
3609
+ /**
3610
+ * Convert a path like `/docs/quickstart` into its llms.txt URL.
3611
+ */
3612
+ function pathToLlmsUrl(origin, path) {
3613
+ if (!path || path === '/') return `${origin}/llms.txt`
3614
+ return `${origin}${path}/llms.txt`
3615
+ }
3616
+
3617
+ function llmsParseOptionsForUrl(llmsUrl) {
3618
+ return { rootRelativeResolution: llmsPathFromUrl(llmsUrl) === '/' ? 'origin' : 'llms-dir' }
3619
+ }
3620
+
3621
+ /**
3622
+ * Derive the path a fetched llms.txt "lives at" (its pathname minus the
3623
+ * trailing `/llms.txt`), so explicit-link hits carry the same `path` shape
3624
+ * the slug-walk hits do — mergeValidHits sorts by it (deepest wins) and
3625
+ * narrowToDocsSubtreeIfNeeded reads it.
3626
+ */
3627
+ function llmsPathFromUrl(llmsUrl) {
3628
+ const pn = new URL(llmsUrl).pathname.replace(/\/llms\.txt$/i, '')
3629
+ return pn || '/'
3630
+ }
3631
+
3632
+ /**
3633
+ * Pull explicit child-index links out of a parsed llms.txt — item rows whose
3634
+ * URL literally points at another `llms.txt` (e.g. Snowflake's root file
3635
+ * listing `…/data-integration/llms.txt`). Restricted to the same origin as the
3636
+ * source: cross-origin llms.txt would drag in unrelated third-party docs, and
3637
+ * the slug-walk's path math assumes one origin. Returns deduped URL strings.
3638
+ */
3639
+ function extractNestedLlmsUrls(parsed, originScope) {
3640
+ const out = new Set()
3641
+ for (const section of parsed.sections) {
3642
+ for (const item of section.items) {
3643
+ try {
3644
+ const u = new URL(item.url)
3645
+ if (u.origin === originScope && LLMS_INDEX_RE.test(u.pathname)) out.add(u.toString())
3646
+ } catch {
3647
+ // ignore unparseable URLs
3648
+ }
2352
3649
  }
3650
+ }
3651
+ return [...out]
3652
+ }
2353
3653
 
2354
- const h2 = line.match(/^##\s+(.+)$/)
2355
- if (h2) {
2356
- current = { title: h2[1].trim(), items: [] }
2357
- sections.push(current)
2358
- continue
3654
+ /**
3655
+ * Given a current path + a parsed llms.txt, return the next paths to probe:
3656
+ * the parent directory (UP) and distinct first-segment-children extracted
3657
+ * from the URL list (DOWN). Children are scoped to URLs whose pathname
3658
+ * actually starts with the current path's prefix — out-of-scope URLs (e.g.
3659
+ * a `/docs/llms.txt` listing `/blog/foo`) are ignored.
3660
+ */
3661
+ function expandLlmsFrontier(currentPath, parsed) {
3662
+ const next = []
3663
+ const prefix = currentPath === '/' || currentPath === '' ? '/' : currentPath + '/'
3664
+
3665
+ if (currentPath && currentPath !== '/') {
3666
+ const parent = currentPath.replace(/\/[^/]+$/, '') || '/'
3667
+ next.push(parent)
3668
+ }
3669
+
3670
+ const childSegs = new Set()
3671
+ for (const section of parsed.sections) {
3672
+ for (const item of section.items) {
3673
+ try {
3674
+ const u = new URL(item.url)
3675
+ const pn = u.pathname.toLowerCase()
3676
+ if (!pn.startsWith(prefix.toLowerCase())) continue
3677
+ const rest = pn.slice(prefix.length).split('/').filter(Boolean)
3678
+ if (rest.length > 0) childSegs.add(rest[0])
3679
+ } catch {
3680
+ // ignore unparseable URLs
3681
+ }
3682
+ if (childSegs.size >= LLMS_CHILDREN_PER_NODE) break
2359
3683
  }
3684
+ if (childSegs.size >= LLMS_CHILDREN_PER_NODE) break
3685
+ }
3686
+ for (const seg of childSegs) {
3687
+ next.push(currentPath === '/' || !currentPath ? `/${seg}` : `${currentPath}/${seg}`)
3688
+ }
3689
+ return next
3690
+ }
2360
3691
 
2361
- const item = line.match(itemRe)
2362
- if (item) {
2363
- if (!current) {
2364
- current = { title: 'Resources', items: [] }
2365
- sections.push(current)
3692
+ /**
3693
+ * BFS-discover all llms.txt files reachable from sourceUrl by walking UP
3694
+ * the path and DOWN into child segments. Invalid hits (file exists but
3695
+ * fails the usability ratio) are kept as "stepping stones" — they still
3696
+ * expand the frontier so we can find their valid siblings/children — but
3697
+ * don't contribute content to the merged result. Returns:
3698
+ * { hits, misses, skipped }
3699
+ * where `hits` includes both valid (usable: true) and invalid hits.
3700
+ */
3701
+ async function discoverLlmsTxt(sourceUrl) {
3702
+ const tried = new Set()
3703
+ const hits = []
3704
+ const misses = []
3705
+ const skipped = []
3706
+
3707
+ const startSegs = sourceUrl.pathname.split('/').filter(Boolean)
3708
+ const initial = []
3709
+ for (let i = startSegs.length; i >= 0; i--) {
3710
+ initial.push('/' + startSegs.slice(0, i).join('/'))
3711
+ }
3712
+
3713
+ let frontier = initial
3714
+ while (frontier.length > 0 && tried.size < LLMS_PROBE_CAP) {
3715
+ const ring = []
3716
+ for (const p of frontier) {
3717
+ const norm = p === '' ? '/' : p
3718
+ if (tried.has(norm)) continue
3719
+ tried.add(norm)
3720
+ ring.push(norm)
3721
+ if (tried.size >= LLMS_PROBE_CAP) break
3722
+ }
3723
+ if (ring.length === 0) break
3724
+
3725
+ const results = await Promise.all(
3726
+ ring.map(async (path) => {
3727
+ const llmsUrl = pathToLlmsUrl(sourceUrl.origin, path)
3728
+ const res = await fetchLlmsTxt(llmsUrl, llmsParseOptionsForUrl(llmsUrl))
3729
+ return { path, llmsUrl, res }
3730
+ }),
3731
+ )
3732
+
3733
+ const next = []
3734
+ for (const { path, llmsUrl, res } of results) {
3735
+ if (!res.ok) {
3736
+ misses.push({ url: llmsUrl, status: res.status, error: res.error })
3737
+ continue
2366
3738
  }
2367
- current.items.push({
2368
- text: item[1].trim(),
2369
- url: item[2].replace(/[.,;]+$/, ''),
2370
- description: item[3] ? item[3].trim() : null,
2371
- })
3739
+ // Treat as a "hit" only if there's at least one link row — that's the
3740
+ // minimum signal that this is an llms.txt-shaped file (and not just a
3741
+ // 200 response serving the site's HTML or a stray text file). Hits
3742
+ // expand the frontier regardless of whether they pass usability.
3743
+ const linkItems = res.stats?.linkItems ?? 0
3744
+ if (linkItems === 0) {
3745
+ misses.push({ url: llmsUrl, status: 'no link items' })
3746
+ continue
3747
+ }
3748
+ hits.push({ ...res, llmsUrl, path })
3749
+ if (!res.usable) skipped.push({ url: llmsUrl, reason: res.reason })
3750
+ for (const np of expandLlmsFrontier(path, res.parsed)) {
3751
+ if (!tried.has(np)) next.push(np)
3752
+ }
3753
+ }
3754
+ frontier = next
3755
+ }
3756
+
3757
+ // Phase 2 — follow EXPLICIT llms.txt links authored inside the files phase 1
3758
+ // discovered, recursively. No slug-guessing here: we only fetch URLs that
3759
+ // literally point at an llms.txt (a root index listing per-section files,
3760
+ // like docs.snowflake.com). Each new file's own explicit links are followed
3761
+ // in turn until none remain. Dedupes against every URL phase 1 touched
3762
+ // (hits + misses) and against itself; phase 1's `tried` set is keyed by path,
3763
+ // so we track full llms.txt URLs separately here and leave phase 1 untouched.
3764
+ const triedUrls = new Set([...hits.map((h) => h.llmsUrl), ...misses.map((m) => m.url)])
3765
+ let explicitFrontier = []
3766
+ for (const hit of hits) {
3767
+ for (const u of extractNestedLlmsUrls(hit.parsed, sourceUrl.origin)) {
3768
+ if (!triedUrls.has(u)) explicitFrontier.push(u)
2372
3769
  }
2373
3770
  }
2374
3771
 
2375
- return { title, sections }
3772
+ let explicitProbes = 0
3773
+ while (explicitFrontier.length > 0 && explicitProbes < EXPLICIT_LLMS_PROBE_CAP) {
3774
+ const ring = []
3775
+ for (const u of explicitFrontier) {
3776
+ if (triedUrls.has(u)) continue
3777
+ triedUrls.add(u)
3778
+ ring.push(u)
3779
+ explicitProbes++
3780
+ if (explicitProbes >= EXPLICIT_LLMS_PROBE_CAP) break
3781
+ }
3782
+ if (ring.length === 0) break
3783
+
3784
+ const results = await Promise.all(ring.map(async (llmsUrl) => ({ llmsUrl, res: await fetchLlmsTxt(llmsUrl, llmsParseOptionsForUrl(llmsUrl)) })))
3785
+
3786
+ const next = []
3787
+ for (const { llmsUrl, res } of results) {
3788
+ if (!res.ok) {
3789
+ misses.push({ url: llmsUrl, status: res.status, error: res.error })
3790
+ continue
3791
+ }
3792
+ const linkItems = res.stats?.linkItems ?? 0
3793
+ if (linkItems === 0) {
3794
+ misses.push({ url: llmsUrl, status: 'no link items' })
3795
+ continue
3796
+ }
3797
+ hits.push({ ...res, llmsUrl, path: llmsPathFromUrl(llmsUrl) })
3798
+ if (!res.usable) skipped.push({ url: llmsUrl, reason: res.reason })
3799
+ for (const nested of extractNestedLlmsUrls(res.parsed, sourceUrl.origin)) {
3800
+ if (!triedUrls.has(nested)) next.push(nested)
3801
+ }
3802
+ }
3803
+ explicitFrontier = next
3804
+ }
3805
+
3806
+ return { hits, misses, skipped }
3807
+ }
3808
+
3809
+ /**
3810
+ * Merge content from all VALID hits into one llms-result-shaped object that
3811
+ * downstream code can consume the same as a single-file result. Pages are
3812
+ * deduped by normalized pathname, with the deepest-path hit winning — the
3813
+ * `/docs/conversational-ai/` file's section assignment beats `/docs/`'s for
3814
+ * any URL under that subtree. Returns null when there are no valid hits.
3815
+ */
3816
+ function mergeValidHits(hits) {
3817
+ const valid = hits.filter((h) => h.usable)
3818
+ if (valid.length === 0) return null
3819
+
3820
+ const deepestFirst = [...valid].sort((a, b) => b.path.length - a.path.length)
3821
+ const shallowestFirst = [...valid].sort((a, b) => a.path.length - b.path.length)
3822
+
3823
+ const claimed = new Map()
3824
+ const sections = []
3825
+ for (const hit of deepestFirst) {
3826
+ for (const section of hit.parsed.sections) {
3827
+ const items = []
3828
+ for (const item of section.items) {
3829
+ const key = llmsPageDedupeKey(item.url)
3830
+ const existing = claimed.get(key)
3831
+ if (existing) {
3832
+ if (compareCanonicalUrlPreference(item.url, existing.url) < 0) existing.url = item.url
3833
+ continue
3834
+ }
3835
+ claimed.set(key, item)
3836
+ items.push(item)
3837
+ }
3838
+ if (items.length > 0) sections.push({ title: section.title, items })
3839
+ }
3840
+ }
3841
+
3842
+ // Prefer the root-most file's H1 title — usually the canonical site name.
3843
+ const title = shallowestFirst.find((h) => h.parsed.title)?.parsed.title || null
3844
+
3845
+ const stats = valid.reduce(
3846
+ (acc, h) => ({
3847
+ conforming: acc.conforming + (h.stats?.conforming || 0),
3848
+ nonConforming: acc.nonConforming + (h.stats?.nonConforming || 0),
3849
+ total: acc.total + (h.stats?.total || 0),
3850
+ linkItems: acc.linkItems + (h.stats?.linkItems || 0),
3851
+ }),
3852
+ { conforming: 0, nonConforming: 0, total: 0, linkItems: 0 },
3853
+ )
3854
+ stats.ratio = stats.total === 0 ? 0 : stats.conforming / stats.total
3855
+
3856
+ return {
3857
+ parsed: { title, sections },
3858
+ usable: true,
3859
+ reason: null,
3860
+ stats,
3861
+ llmsUrl: shallowestFirst[0].llmsUrl,
3862
+ sourceFiles: shallowestFirst.map((h) => h.llmsUrl),
3863
+ }
3864
+ }
3865
+
3866
+ /**
3867
+ * Best-effort fetch of a site's /llms.txt plus a simple structural usability check.
3868
+ */
3869
+ async function fetchLlmsTxt(llmsUrl, options = {}) {
3870
+ try {
3871
+ const res = await fetch(llmsUrl, {
3872
+ redirect: 'follow',
3873
+ headers: { 'User-Agent': 'readme-cli-import' },
3874
+ })
3875
+ if (!res.ok) return { ok: false, status: res.status }
3876
+ const text = await res.text()
3877
+ const finalUrl = res.url || String(llmsUrl)
3878
+ const analysis = analyzeLlmsTxt(text, finalUrl, finalUrl === String(llmsUrl) ? options : llmsParseOptionsForUrl(finalUrl))
3879
+ return {
3880
+ ok: true,
3881
+ status: res.status,
3882
+ finalUrl: res.url,
3883
+ parsed: analysis.parsed,
3884
+ usable: analysis.usable,
3885
+ reason: analysis.reason,
3886
+ stats: analysis.stats,
3887
+ }
3888
+ } catch (e) {
3889
+ return { ok: false, error: e.message }
3890
+ }
2376
3891
  }
2377
3892
 
2378
3893
  // Path segments that mark a docs scope. Used to find the right "base" for
@@ -2554,13 +4069,6 @@ function sitemapUrlsToKnownUrls(urls) {
2554
4069
  return out
2555
4070
  }
2556
4071
 
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
4072
  /**
2565
4073
  * Recursively print the page tree. Sub-pages are indented under their parent
2566
4074
  * with no leading bullet character, to show them as children of the parent.
@@ -2577,27 +4085,102 @@ function printPagesTree(pages, indentLevel) {
2577
4085
  }
2578
4086
  }
2579
4087
 
4088
+ /**
4089
+ * Collapse redundant single-folder layers in a category/page subtree, in place.
4090
+ *
4091
+ * When a container (category or page) has exactly one child and that child is a
4092
+ * pure folder node — no `x-import-url` gitto/readme has an issue with unique slug
4093
+ * generation.
4094
+ * This is a temporary workaround.
4095
+ * TODO: Remove after fixing on the Gitto/ReadMe side.
4096
+ */
4097
+ function collapseRedundantLayers(container) {
4098
+ for (const child of container.pages || []) collapseRedundantLayers(child)
4099
+ while ((container.pages || []).length === 1) {
4100
+ const only = container.pages[0]
4101
+ if (only.url) break // real page (gets x-import), just has children
4102
+ if (!only.pages || only.pages.length === 0) break // nothing to lift up
4103
+ container.pages = only.pages
4104
+ }
4105
+ }
4106
+
4107
+ // Some sites serve regular guides under an /api/docs/* path (e.g. /api/docs/guides/*,
4108
+ // /api/docs/pricing). Treat a URL as real docs when it has both `api` and `docs` segments
4109
+ // and the segment right after `docs` is not itself a reference/api slug.
4110
+ function isDocsUnderApiPath(url) {
4111
+ try {
4112
+ const segs = new URL(url).pathname.split('/').filter(Boolean).map((s) => s.toLowerCase())
4113
+ const docsIdx = segs.findIndex((s) => s === 'docs' || s === 'doc')
4114
+ if (!segs.includes('api') || docsIdx === -1) return false
4115
+ const afterDocs = segs[docsIdx + 1]
4116
+ return !afterDocs || !/^(api|api[-_]?reference|reference)$/.test(afterDocs)
4117
+ } catch {
4118
+ return false
4119
+ }
4120
+ }
4121
+
4122
+ // Prune a page tree, keeping only URL pages whose url passes `predicate` plus
4123
+ // empty-parent nodes that still have surviving descendants. Nesting preserved.
4124
+ function filterUrlPagesTree(pages, predicate) {
4125
+ const out = []
4126
+ for (const p of pages || []) {
4127
+ const keptKids = filterUrlPagesTree(p.pages, predicate)
4128
+ if (p.url) {
4129
+ if (predicate(p.url)) out.push({ ...p, pages: keptKids })
4130
+ } else if (keptKids.length > 0) {
4131
+ out.push({ ...p, pages: keptKids })
4132
+ }
4133
+ }
4134
+ return out
4135
+ }
4136
+
4137
+ /**
4138
+ * Write the organized hierarchy to disk as git-format markdown stubs — just
4139
+ * frontmatter, no body yet. docs/ pages go under docs/<Category>/<slug>.md;
4140
+ * reference/recipes/custom_pages/custom_blocks get their own top-level dir
4141
+ * without a category subfolder (the git-format schema doesn't nest them).
4142
+ * Writes _order.yaml per directory so sidebar order matches input order.
4143
+ */
2580
4144
  function stageOrganized(organized, stagingDir, opts = {}) {
2581
4145
  const pickIcon = makeIconPicker()
2582
4146
  const byDir = new Map()
2583
4147
  const subDirsByTopDir = new Map()
2584
4148
  const counts = { fileCount: 0, skippedApiRef: 0 }
2585
4149
  const skipApiReference = !!opts.skipApiReference
4150
+ const llmsPaths = opts.llmsPaths || new Set()
2586
4151
 
2587
- const eligibleCategories = (organized.categories || []).filter((cat) => {
4152
+ const eligibleCategories = []
4153
+ for (const cat of organized.categories || []) {
2588
4154
  if (skipApiReference && routeCategory(cat.title).topDir === 'reference') {
2589
- counts.skippedApiRef += countPagesDeep(cat.pages || [])
2590
- return false
4155
+ // A reference-routed cluster can mix genuine OAS pages (/api/reference/*)
4156
+ // with real guides served under /api/docs/*. Rescue the docs subtree —
4157
+ // retitled so it routes to docs/ instead of the dropped reference/
4158
+ const rescuedPages = filterUrlPagesTree(cat.pages || [], isDocsUnderApiPath)
4159
+ const totalDeep = collectUrlPagesDeep(cat.pages || []).length
4160
+ const rescuedDeep = collectUrlPagesDeep(rescuedPages).length
4161
+ if (rescuedDeep > 0) {
4162
+ eligibleCategories.push({ ...cat, title: 'API Docs', pages: rescuedPages })
4163
+ }
4164
+ counts.skippedApiRef += totalDeep - rescuedDeep
4165
+ continue
2591
4166
  }
2592
- return true
2593
- })
4167
+ eligibleCategories.push(cat)
4168
+ }
4169
+
4170
+ // TODO: Remove after readme/gitto handles better
4171
+ for (const cat of eligibleCategories) collapseRedundantLayers(cat)
2594
4172
 
2595
4173
  // Slug names must be unique
2596
4174
  const slugFor = ensureUniqueSlugs(eligibleCategories)
2597
4175
 
2598
- const urlWidth = Math.max(...[...slugFor.keys()].map((p) => (p.url || '(group-only)').length))
4176
+ const labelFor = (p) => {
4177
+ if (p.url) return p.url
4178
+ if (p._emptyParent && p._virtualPathSegs) return `(empty) /${p._virtualPathSegs.join('/')}`
4179
+ return '(group-only)'
4180
+ }
4181
+ const urlWidth = Math.max(...[...slugFor.keys()].map((p) => labelFor(p).length))
2599
4182
  for (const [page, slug] of slugFor) {
2600
- console.log(`${(page.url || '(group-only)').padEnd(urlWidth)} → ${slug}`)
4183
+ console.log(`${labelFor(page).padEnd(urlWidth)} → ${slug}`)
2601
4184
  }
2602
4185
 
2603
4186
  /**
@@ -2613,18 +4196,32 @@ function stageOrganized(organized, stagingDir, opts = {}) {
2613
4196
  // no backing page on the source site — they're pure sidebar containers.
2614
4197
  // Skip the stub write but still recurse so their children land in the
2615
4198
  // right subdirectory.
2616
- const isGroupOnly = !page.url
4199
+ const isEmptyParent = !!page._emptyParent
4200
+ const isGroupOnly = !page.url && !isEmptyParent
2617
4201
 
2618
4202
  if (!isGroupOnly) {
2619
4203
  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'
4204
+ let frontmatter
4205
+ if (isEmptyParent) {
4206
+ // Synthetic parent page: covers a missing URL segment so descendants
4207
+ // nest at the right depth. No source URL to import. Reuse the same
4208
+ // pickIcon heuristic leaf pages use so the parent gets a topical
4209
+ // icon — the sidebar already renders an expand chevron, so a
4210
+ // hardcoded `fa-folder` would just look like a placeholder.
4211
+ frontmatter = { title: page.title, icon: formatIconClass(pickIcon(slug, page.title)) }
4212
+ } else {
4213
+ // Sub-pages don't get icons per design decision.
4214
+ frontmatter = buildFrontmatter(topDir, page, slug, pickIcon, { skipIcon: isSubPage })
4215
+ // x-import points at the source URL for this stub. The content-import
4216
+ // step reads it to fetch the page body. x-prefixed custom field is the
4217
+ // git-format convention for metadata the schema doesn't know about.
4218
+ frontmatter['x-import'] = toBrowsableUrl(page.url)
4219
+ // Pages sourced from llms.txt serve raw markdown at `<url>.md`; flag them
4220
+ // so the runner knows it can append `.md` when fetching the body.
4221
+ if (llmsPaths.has(normalizePath(page.url))) frontmatter['x-from-llms'] = 'true'
4222
+ // hide pages that need import
4223
+ frontmatter.hidden = 'true'
4224
+ }
2628
4225
 
2629
4226
  const absPath = path.join(stagingDir, relFilePath)
2630
4227
  fs.mkdirSync(path.dirname(absPath), { recursive: true })
@@ -2679,6 +4276,81 @@ function countPagesDeep(pages) {
2679
4276
  return n
2680
4277
  }
2681
4278
 
4279
+ /**
4280
+ * Post-staging relocation for `--separate-changelog`: move the staged
4281
+ * `docs/Changelog/` category up to a top-level `changelog/` directory.
4282
+ *
4283
+ * Done as a filesystem move *after* staging (rather than routing there
4284
+ * directly) so the staged tree is a valid git-format layout up to this point,
4285
+ * and the one schema-divergent step lives in a single, clearly-named place.
4286
+ * Note `changelog/` is not part of the git-format schema — it won't lint or
4287
+ * sync to ReadMe until git-format adds first-class changelog support.
4288
+ *
4289
+ * Returns the number of changelog pages moved (0 if there were none).
4290
+ */
4291
+ function relocateChangelogDir(stagingDir) {
4292
+ const srcDir = path.join(stagingDir, 'docs', 'Changelog')
4293
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return 0
4294
+
4295
+ const dstDir = path.join(stagingDir, 'changelog')
4296
+ fs.renameSync(srcDir, dstDir)
4297
+
4298
+ // Collect every real x-import page from the (possibly nested) tree.
4299
+ // Synthetic group-only stubs (no x-import — they exist only to render an
4300
+ // empty parent in the sidebar) are dropped: the flat layout has no concept
4301
+ // of containers, so they'd just be dead frontmatter.
4302
+ const pages = []
4303
+ const walk = (dir, ancestors) => {
4304
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
4305
+ const full = path.join(dir, entry.name)
4306
+ if (entry.isDirectory()) {
4307
+ walk(full, [...ancestors, entry.name])
4308
+ continue
4309
+ }
4310
+ if (!entry.name.endsWith('.md')) continue
4311
+ const parsed = matter(fs.readFileSync(full, 'utf-8'))
4312
+ if (!parsed.data || !parsed.data['x-import']) continue
4313
+ const slug = entry.name.replace(/\.md$/, '')
4314
+ pages.push({ ancestors, slug, content: parsed.content, data: parsed.data })
4315
+ }
4316
+ }
4317
+ walk(dstDir, [])
4318
+
4319
+ // Wipe the nested tree and rewrite it flat. Filename concatenates the
4320
+ // ancestor folder slugs with the leaf slug so siblings share a prefix —
4321
+ // alphabetical order then naturally matches authored reading order, and
4322
+ // names are unique across the (formerly nested) tree.
4323
+ fs.rmSync(dstDir, { recursive: true, force: true })
4324
+ fs.mkdirSync(dstDir, { recursive: true })
4325
+
4326
+ const flatSlugs = []
4327
+ for (const p of pages) {
4328
+ const flatSlug = [...p.ancestors, p.slug].join('-')
4329
+ const frontmatter = { ...p.data }
4330
+ delete frontmatter.icon
4331
+ fs.writeFileSync(path.join(dstDir, `${flatSlug}.md`), matter.stringify(p.content, frontmatter))
4332
+ flatSlugs.push(flatSlug)
4333
+ }
4334
+
4335
+ flatSlugs.sort()
4336
+ fs.writeFileSync(path.join(dstDir, '_order.yaml'), flatSlugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
4337
+
4338
+ // docs/_order.yaml lists the docs category subfolders — drop the moved one
4339
+ // so the docs sidebar doesn't point at a folder that's no longer there.
4340
+ const docsOrderPath = path.join(stagingDir, 'docs', '_order.yaml')
4341
+ if (fs.existsSync(docsOrderPath)) {
4342
+ const entries = yamlRequire().load(fs.readFileSync(docsOrderPath, 'utf-8'))
4343
+ const filtered = Array.isArray(entries) ? entries.filter((e) => e !== 'Changelog') : []
4344
+ if (filtered.length > 0) {
4345
+ fs.writeFileSync(docsOrderPath, filtered.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n')
4346
+ } else {
4347
+ fs.rmSync(docsOrderPath)
4348
+ }
4349
+ }
4350
+
4351
+ return pages.length
4352
+ }
4353
+
2682
4354
  /**
2683
4355
  * Map a category title to the git-format top-level directory + optional
2684
4356
  * category subdir. docs/ is the only top dir that takes a subfolder.
@@ -2686,6 +4358,9 @@ function countPagesDeep(pages) {
2686
4358
  function routeCategory(title) {
2687
4359
  const t = (title || '').trim()
2688
4360
  if (/^(api[ -]?reference|reference|api|endpoints?)$/i.test(t)) return { topDir: 'reference', subDir: null }
4361
+ // Changelog-ish categories normalize to one docs/Changelog/ folder so the
4362
+ // --separate-changelog relocation has a single, predictable source dir.
4363
+ if (/^(changelog|change[ -]?log|release[ -]?notes?|releases?|what'?s[ -]?new)$/i.test(t)) return { topDir: 'docs', subDir: 'Changelog' }
2689
4364
  if (/^(recipes?|cookbook|tutorials?|how[ -]?tos?)$/i.test(t)) return { topDir: 'recipes', subDir: null }
2690
4365
  if (/^(custom[ -]?pages?|landing( page)?s?)$/i.test(t)) return { topDir: 'custom_pages', subDir: null }
2691
4366
  if (/^(custom[ -]?blocks?|snippets?|reusable( content)?)$/i.test(t)) return { topDir: 'custom_blocks', subDir: null }
@@ -2722,30 +4397,19 @@ function buildFrontmatter(topDir, page, slug, pickIcon, opts = {}) {
2722
4397
  }
2723
4398
 
2724
4399
  /**
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
- }
4400
+ // extractUrlPathSegments is imported from ../utils/url-segs.js
2741
4401
 
2742
4402
  /**
2743
4403
  * Compute a globally-unique slug for every page in the tree.
2744
4404
  *
2745
4405
  * Base slug is the last URL segment (kebab-cased). When two or more pages
2746
4406
  * share a base, we prepend the next-up URL segment to every member of the
2747
- * colliding group and recheck
2748
- *
4407
+ * colliding group and recheck. The category title is treated as the
4408
+ * outermost segment, so when URL segments alone aren't enough (e.g. a
4409
+ * group-only `/v1` parent that appears in multiple categories), expansion
4410
+ * can still reach for the category to disambiguate before falling back to
4411
+ * a numeric `-N` suffix.
4412
+ *
2749
4413
  * @example
2750
4414
  * ensureUniqueSlugs([
2751
4415
  * { title: 'Getting Started', pages: [
@@ -2762,15 +4426,47 @@ function extractUrlPathSegments(url) {
2762
4426
  * // <Overview (Guides)> => 'guides-overview', // expanded (collides)
2763
4427
  * // }
2764
4428
  */
2765
- function ensureUniqueSlugs(categories) {
4429
+ export function ensureUniqueSlugs(categories) {
2766
4430
  const entries = []
2767
- const walk = (pages) => {
4431
+ const segmentsFor = (p) => {
4432
+ if (p._virtualPathSegs && p._virtualPathSegs.length > 0) {
4433
+ return p._virtualPathSegs
4434
+ .map((s) => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
4435
+ .filter(Boolean)
4436
+ }
4437
+ return extractUrlPathSegments(p.url)
4438
+ }
4439
+ const normalizedSegmentsFor = (p) => {
4440
+ const semanticSegments = segmentsFor(p).filter((seg) => !isGenericDocsSegment(seg))
4441
+ return semanticSegments.length > 0 ? semanticSegments : ['introduction']
4442
+ }
4443
+ const expansionSegmentsFor = (p, categorySeg) => {
4444
+ const pageSegments = normalizedSegmentsFor(p)
4445
+ if (!categorySeg || isGenericDocsSegment(categorySeg)) return pageSegments
4446
+ return [categorySeg, ...pageSegments]
4447
+ }
4448
+ const walk = (pages, categorySeg) => {
2768
4449
  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)
4450
+ const segments = expansionSegmentsFor(p, categorySeg)
4451
+ // Synthetic/empty-parent nodes have no content. Never let one win a bare
4452
+ // single-segment slug (e.g. a placeholder `…/vercel-flags/cli` claiming
4453
+ // `cli`), or it squats the slug a real overview page wants and ReadMe
4454
+ // redirects the content-less parent to its first child. Floor their slug
4455
+ // at 2 trailing segments so they namespace to `vercel-flags-cli` instead.
4456
+ entries.push({ page: p, segments, fallback: p.title, depth: p._emptyParent ? 2 : 1 })
4457
+ if (p.pages) walk(p.pages, categorySeg)
2771
4458
  }
2772
4459
  }
2773
- for (const c of categories || []) walk(c.pages)
4460
+ const genericCategoriesFirst = [...(categories || [])].sort((a, b) => {
4461
+ const aGeneric = isGenericDocsSegment(a?.title || '')
4462
+ const bGeneric = isGenericDocsSegment(b?.title || '')
4463
+ return Number(bGeneric) - Number(aGeneric)
4464
+ })
4465
+ for (const c of genericCategoriesFirst) {
4466
+ const rawTitle = (c?.title || '').trim()
4467
+ const categorySeg = rawTitle ? kebabCase(rawTitle) : ''
4468
+ walk(c.pages, categorySeg)
4469
+ }
2774
4470
 
2775
4471
  const slugFor = (e) => {
2776
4472
  if (e.segments.length === 0) return kebabCase(e.fallback || 'page') || 'page'
@@ -2813,7 +4509,8 @@ function ensureUniqueSlugs(categories) {
2813
4509
  slug = `${base}-${n}`
2814
4510
  styles.error(
2815
4511
  `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.`,
4512
+ `Pages were deduped by path before organize and category title is already part of the slug, ` +
4513
+ `so this indicates the organize step produced duplicates within a single category.`,
2817
4514
  )
2818
4515
  }
2819
4516
  used.add(slug)
@@ -2822,6 +4519,11 @@ function ensureUniqueSlugs(categories) {
2822
4519
  return result
2823
4520
  }
2824
4521
 
4522
+ const GENERIC_DOCS_SEGMENTS = new Set(['doc', 'docs', 'documentation', 'documentations'])
4523
+ function isGenericDocsSegment(segment) {
4524
+ return GENERIC_DOCS_SEGMENTS.has(kebabCase(segment))
4525
+ }
4526
+
2825
4527
  // Values YAML interprets as non-strings need quoting when used as _order entries.
2826
4528
  const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i
2827
4529
  function yamlSafeSlug(slug) {
@@ -2930,6 +4632,8 @@ function makeIconPicker() {
2930
4632
  }
2931
4633
  }
2932
4634
 
4635
+ export const __test__ = { discoverLlmsTxt, mergeValidHits, resolveRedirectedSourceUrl }
4636
+
2933
4637
  function formatDuration(ms) {
2934
4638
  const safe = Math.max(0, ms)
2935
4639
  // Show ms under a second so sub-second work doesn't misleadingly read as "0m 0s".