@readme/cli 0.0.26 → 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 (123) hide show
  1. package/README.md +2 -2
  2. package/package.json +5 -3
  3. package/src/cli.js +6 -1
  4. package/src/commands/components.js +288 -0
  5. package/src/commands/import.js +2456 -376
  6. package/src/commands/import.slug-planner.test.js +86 -0
  7. package/src/commands/import.test.js +136 -0
  8. package/src/commands/lint.js +32 -35
  9. package/src/commands/oas-sync.js +60 -75
  10. package/src/commands/pretty.js +314 -77
  11. package/src/prompts/index.js +187 -24
  12. package/src/utils/lint.js +33 -33
  13. package/src/utils/llms.js +224 -0
  14. package/src/utils/llms.test.js +257 -0
  15. package/src/utils/marketplace.js +208 -0
  16. package/src/utils/multi-spinner.js +71 -0
  17. package/src/utils/promise-pool.js +17 -0
  18. package/src/utils/url-segs.js +124 -0
  19. package/src/utils/url-segs.test.js +239 -0
  20. package/src/validators/components.js +1 -0
  21. package/src/validators/duplicates.js +6 -4
  22. package/src/validators/frontmatter.js +11 -0
  23. package/src/validators/oas-reference.js +1 -31
  24. package/src/validators/ordering.js +169 -83
  25. package/vendor/TOOLS.md +1 -1
  26. package/src/dev/.next/app-build-manifest.json +0 -20
  27. package/src/dev/.next/build-manifest.json +0 -31
  28. package/src/dev/.next/cache/.rscinfo +0 -1
  29. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  30. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  40. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  43. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  50. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  69. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  70. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  71. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  72. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  73. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  74. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  75. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  76. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  77. package/src/dev/.next/package.json +0 -1
  78. package/src/dev/.next/prerender-manifest.json +0 -11
  79. package/src/dev/.next/react-loadable-manifest.json +0 -1
  80. package/src/dev/.next/routes-manifest.json +0 -1
  81. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  82. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  83. package/src/dev/.next/server/app/page.js +0 -349
  84. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  85. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  86. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  87. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  88. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  89. package/src/dev/.next/server/middleware-manifest.json +0 -32
  90. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  91. package/src/dev/.next/server/middleware.js +0 -1113
  92. package/src/dev/.next/server/next-font-manifest.js +0 -1
  93. package/src/dev/.next/server/next-font-manifest.json +0 -1
  94. package/src/dev/.next/server/pages-manifest.json +0 -5
  95. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  96. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  97. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  98. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  99. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  100. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  101. package/src/dev/.next/server/webpack-runtime.js +0 -209
  102. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  103. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  104. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  105. package/src/dev/.next/static/chunks/app/page.js +0 -28
  106. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  107. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  108. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  109. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  110. package/src/dev/.next/static/css/app/layout.css +0 -559
  111. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  112. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  113. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  114. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  115. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  116. package/src/dev/.next/trace +0 -21
  117. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  118. package/src/dev/.next/types/app/layout.ts +0 -84
  119. package/src/dev/.next/types/app/page.ts +0 -84
  120. package/src/dev/.next/types/cache-life.d.ts +0 -141
  121. package/src/dev/.next/types/package.json +0 -1
  122. package/src/dev/.next/types/routes.d.ts +0 -55
  123. package/src/validators/numbering.js +0 -136
@@ -7,9 +7,20 @@ import { Option } from 'commander'
7
7
  import { query } from '@anthropic-ai/claude-agent-sdk'
8
8
  import matter from 'gray-matter'
9
9
  import * as styles from '../utils/styles.js'
10
- import { syncOas, extractOperations } from './oas-sync.js'
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.option('--model <name>', 'Claude model alias: haiku, sonnet, opus', '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,57 +95,368 @@ 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
 
359
+ let sitemapUrl = null
360
+ let sitemapKnownUrls = []
98
361
  if (!llms) {
99
- styles.warning(`No llms.txt found at any probed path — falling back to sidebar discovery via scrape.`)
362
+ // Sitemap fallback: gives us a flat URL list to anchor the scrape (slot
363
+ // orphans by path) even though it lacks llms.txt's section structure.
364
+ // Scope is narrowed to the docs subtree of the source URL when possible
365
+ // so we don't pull in marketing/blog pages from a whole-site sitemap.
366
+ const sitemapCandidates = buildSitemapCandidates(sourceUrl)
367
+ const scopePrefix = deriveSitemapScope(sourceUrl)
368
+ styles.info(
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)}` : ''}...`,
370
+ )
371
+ const sitemapResult = await timePhase('fetch sitemap.xml', async () => {
372
+ for (const candidate of sitemapCandidates) {
373
+ const res = await fetchSitemap(candidate, scopePrefix)
374
+ if (res.ok && res.urls.length > 0) return { url: candidate, urls: res.urls }
375
+ const reason = res.ok ? '0 urls in scope' : res.status ? `HTTP ${res.status}` : res.error || 'failed'
376
+ styles.info(styles.dim(` ${candidate} → ${reason}`))
377
+ }
378
+ return null
379
+ })
380
+ console.log()
381
+ if (sitemapResult) {
382
+ sitemapUrl = sitemapResult.url
383
+ sitemapKnownUrls = sitemapUrlsToKnownUrls(sitemapResult.urls)
384
+ styles.ok(`Found sitemap.xml at ${styles.bold(sitemapUrl)} — ${styles.bold(String(sitemapKnownUrls.length))} URL${sitemapKnownUrls.length === 1 ? '' : 's'} in scope.`)
385
+ } else {
386
+ styles.warning(`No llms.txt or sitemap.xml found — falling back to sidebar discovery via scrape.`)
387
+ }
100
388
  } else {
101
- 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
+ }
398
+ }
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
+ }
102
444
  }
103
445
 
446
+ const dbgSuffix = `-${sourceUrl.hostname}`
104
447
  if (debugSnapshots) {
105
- debugSnapshots['01-llms-parsed.json'] = { llmsUrl, parsed: llms ? llms.parsed : null }
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
106
451
  }
107
452
 
108
453
  let knownUrls = []
109
454
  if (llms) {
110
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)`
111
458
  styles.ok(
112
- `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})` : ''}.`,
113
460
  )
114
461
 
115
462
  const rawKnownUrls = llms.parsed.sections.flatMap((s) => s.items.map((i) => ({ title: i.text, url: i.url, description: i.description })))
@@ -117,19 +464,21 @@ export async function importDocs(options) {
117
464
  // Dedupe llms.txt entries by pathname. Some sites (zod.dev, fumadocs) list
118
465
  // every in-page anchor as its own llms.txt row (`/v4?id=wrapping-up`,
119
466
  // `/v4?id=metadata`, …) even though they all live on one rendered page.
120
- // We prefer the "cleanest" URL per path — the shortest one, which is
121
- // 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.
122
469
  const byKnownPath = new Map()
123
470
  for (const p of rawKnownUrls) {
124
471
  const key = normalizePath(p.url)
125
472
  const prev = byKnownPath.get(key)
126
- 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)
127
474
  }
128
475
  knownUrls = Array.from(byKnownPath.values())
129
476
  const dropped = rawKnownUrls.length - knownUrls.length
130
477
  if (dropped > 0) {
131
478
  styles.info(`${styles.dim(`Collapsed ${dropped} anchor/query duplicates → ${knownUrls.length} unique pages.`)}`)
132
479
  }
480
+ } else if (sitemapKnownUrls.length > 0) {
481
+ knownUrls = sitemapKnownUrls
133
482
  }
134
483
 
135
484
  console.log()
@@ -142,56 +491,82 @@ export async function importDocs(options) {
142
491
  const mintlifyStart = Date.now()
143
492
  const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey))
144
493
  if (debugSnapshots) {
145
- 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
146
495
  }
147
496
  if (mintlifyNav) {
148
- 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)
149
498
  styles.ok(
150
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.`,
151
500
  )
152
501
  }
153
502
  console.log()
154
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
+
155
521
  let scraped
156
522
  let scrapeStart = Date.now()
523
+ const scrapeDiagnostics = {}
157
524
  if (mintlifyNav) {
158
525
  scraped = { title: mintlifyNav.title, categories: mintlifyNav.categories }
526
+ } else if (archbeeNav) {
527
+ scraped = { title: archbeeNav.title, categories: archbeeNav.categories }
159
528
  } else {
160
529
  styles.info(`Scraping sidebar nav from ${styles.bold(sourceUrl.toString())}${firecrawlKey ? ' ' + styles.dim('(via Firecrawl)') : ''}...`)
161
530
  scrapeStart = Date.now()
162
- 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'
163
533
  }
164
534
  if (debugSnapshots) {
165
- 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
166
536
  }
167
- // Prefer llms.txt when it has strong multi-section structure and the
168
- // scrape was a thin snapshot (common on big multi-tab docs — Stripe, AWS,
169
- // Twilio — where each page only renders its own tab's sidebar). Without
170
- // this override the 4-category scrape wins over a 25-section llms.txt and
171
- // 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
172
547
  if (scraped && llms && knownUrls.length > 0) {
173
- 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)
174
549
  const coverage = scrapedPages / knownUrls.length
175
- const llmsUsable = usableSections(llms.parsed.sections)
176
- if (llmsUsable.length >= 5 && coverage < 0.5) {
550
+ if (coverage < 0.75) {
177
551
  styles.info(
178
- `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.`,
179
553
  )
180
554
  scraped = null
555
+ scrapeDiscardedForCoverage = true
181
556
  }
182
557
  }
183
558
 
184
559
  if (scraped) {
185
- 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)
186
561
  if (knownUrls.length > 0) {
187
562
  const slotted = slotOrphansByPath(scraped, knownUrls)
188
563
  if (debugSnapshots) {
189
- debugSnapshots['03-after-slot-by-path.json'] = {
564
+ debugSnapshots[`03-after-slot-by-path${dbgSuffix}.json`] = {
190
565
  scraped: JSON.parse(JSON.stringify(scraped)),
191
566
  unslottedOrphans: slotted,
192
567
  }
193
568
  }
194
- 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)
195
570
  styles.ok(
196
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}.`,
197
572
  )
@@ -205,40 +580,83 @@ export async function importDocs(options) {
205
580
  styles.info(`Moved ${styles.bold(String(moved))} page${moved === 1 ? '' : 's'} into ${styles.bold('API Reference')} based on URL path.`)
206
581
  }
207
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
+
208
591
  if (slotted.length > 0) {
209
- // Mintlify-style docs put API endpoints in a separate tab rooted at
210
- // /api-reference/* (or /api/*, /reference/*). Collapse remaining such
211
- // pages into a single "API Reference" category (absorbing the flat
212
- // category the sweep pass just built, if any) and nest it by resource
213
- // segment so routeCategory() maps the whole thing to ReadMe's
214
- // `reference/` top-level dir.
215
- const apiResult = collectApiReferencePages(slotted, scraped)
216
- const otherOrphans = apiResult.nonApiOrphans
217
- if (apiResult.category) scraped.categories.push(apiResult.category)
218
-
219
- const buckets = bucketOrphansByPathType(otherOrphans, scraped)
220
- for (const b of buckets) scraped.categories.push(b)
221
- if (debugSnapshots) {
222
- debugSnapshots['04-after-orphan-buckets.json'] = {
223
- apiReferenceCollected: apiResult.category
224
- ? {
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
+ ? {
225
642
  pageCount: apiResult.category.pages.length,
226
643
  mergedFromScraped: apiResult.mergedScrapedTitles,
227
644
  }
228
- : null,
229
- buckets,
230
- 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(', ')}.`)
231
659
  }
232
- }
233
- const parts = []
234
- if (apiResult.category) {
235
- parts.push(`${styles.bold(String(apiResult.category.pages.length))} in ${styles.bold('API Reference')}`)
236
- }
237
- for (const b of buckets) {
238
- parts.push(`${styles.bold(String(b.pages.length))} in ${styles.bold(b.title)}`)
239
- }
240
- if (parts.length > 0) {
241
- styles.info(`${styles.bold(String(slotted.length))} orphan page${slotted.length === 1 ? '' : 's'} bucketed by URL type: ${parts.join(', ')}.`)
242
660
  }
243
661
  }
244
662
  } else {
@@ -265,10 +683,34 @@ export async function importDocs(options) {
265
683
  )
266
684
  }
267
685
  }
268
- } else if (!llms) {
269
- throw new Error(`No llms.txt and the sidebar scrape found no usable structure — can't import ${sourceUrl.toString()}.`)
686
+ } else if (!llms && knownUrls.length === 0) {
687
+ throw new Error(`No llms.txt or sitemap.xml and the sidebar scrape found no usable structure — can't import ${sourceUrl.toString()}.`)
688
+ } else if (llms) {
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
+ }
270
701
  } else {
271
- styles.warning(`Couldn't extract a useful nav — falling back to llms.txt-based organization.`)
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
+ }
272
714
  }
273
715
  console.log()
274
716
 
@@ -281,75 +723,131 @@ export async function importDocs(options) {
281
723
  title: (llms && llms.parsed.title) || null,
282
724
  categories: scraped.categories.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
283
725
  }
726
+ } else if (llms) {
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
+ }
284
781
  } else {
285
- const fastPath = sectionsLookUsable(llms.parsed.sections)
286
- styles.info(`Organizing with Claude (${styles.bold(options.model)}, ${fastPath ? 'fast path: icons only' : 'full reorg'})...`)
287
- organized = await timePhase('claude organize', () => organizeWithClaude(llms.parsed, options.model))
288
- }
289
- styles.ok(`Organized in ${styles.bold(formatDuration(Date.now() - organizeStart))}.`)
290
- if (debugSnapshots) {
291
- debugSnapshots['05-organized.json'] = organized
292
- const debugDir = path.join(os.tmpdir(), `readme-import-debug-${sourceUrl.hostname}-${Date.now()}`)
293
- fs.mkdirSync(debugDir, { recursive: true })
294
- for (const [name, data] of Object.entries(debugSnapshots)) {
295
- fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, null, 2))
782
+ // Sitemap-only fallback: synthesize categories by clustering the URL
783
+ // paths. clusterByUrlPath returns null when the URLs don't split cleanly,
784
+ // in which case everything lands in a single Documentation bucket.
785
+ const clustered = clusterByUrlPath(knownUrls) || [{ title: 'Documentation', pages: knownUrls }]
786
+ organized = {
787
+ title: null,
788
+ categories: clustered.map((c) => ({ title: c.title, icon: null, pages: c.pages })),
296
789
  }
297
- styles.info(`${styles.dim(`Debug snapshots → ${debugDir}`)}`)
298
790
  }
299
- console.log()
300
791
 
301
- console.log(` ${styles.bold(organized.title || '(untitled)')}`)
302
- for (const cat of organized.categories || []) {
303
- console.log()
304
- const iconLabel = cat.icon ? `${styles.brand(`[${cat.icon}]`)} ` : ''
305
- console.log(` ${iconLabel}${styles.bold(cat.title)}`)
306
- printPagesTree(cat.pages || [], 2)
307
- }
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)
308
797
 
309
- const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'readme-import-'))
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)
310
803
 
311
- let result
312
- try {
313
- styles.info(`Staging frontmatter stubs in ${styles.bold(stagingDir)}...`)
314
- const stageStart = Date.now()
315
- const staged = await timePhase('stage stubs', async () => stageOrganized(organized, stagingDir, { skipApiReference: !!options.skipApiReference }))
316
- ensureDocsLandingPage(stagingDir, organized.title || sourceUrl.hostname)
317
- styles.ok(
318
- `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))}.`,
319
- )
320
- if (staged.skippedApiRef > 0) {
321
- styles.info(`Skipped ${styles.bold(String(staged.skippedApiRef))} API reference page${staged.skippedApiRef === 1 ? '' : 's'} (--skip-api-reference)`)
322
- }
323
- console.log()
804
+ for (const cat of organized.categories || []) {
805
+ cat.pages = nestByUrlHierarchy(cat.pages)
806
+ }
324
807
 
325
- if (options.test) {
326
- styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Staged ${styles.bold(String(staged.fileCount))} files at ${styles.bold(stagingDir)}`)
327
- console.log()
328
- styles.info('Starting the dev server for preview...')
329
- console.log()
330
- await runDevPreview(stagingDir)
331
- return { source: 'url', stagingDir, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
332
- }
808
+ styles.ok(`Organized in ${styles.bold(formatDuration(Date.now() - organizeStart))}.`)
809
+ organized.oasJsonUrls = oasJsonUrls
333
810
 
334
- if (staged.fileCount === 0) {
335
- styles.warning('Staging directory is empty — skipping zip.')
336
- 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))
337
816
  }
817
+ }
818
+ organized.llmsPaths = llmsPaths
819
+ return organized
820
+ }
338
821
 
339
- styles.info(`Packaging ${styles.bold(String(staged.fileCount))} files into ${styles.bold(outputZip)}...`)
340
- await timePhase('zip', () => createZip(stagingDir, outputZip))
341
-
342
- console.log()
343
- styles.ok(`Done in ${styles.bold(formatDuration(Date.now() - startedAt))}! Your ReadMe import is ready at ${styles.bold(outputZip)}`)
344
- console.log(styles.dim(` ⏱ ${phases.map((p) => `${p.label} ${formatDuration(p.ms)}`).join(' · ')}`))
345
- result = { source: 'url', outputZip, fileCount: staged.fileCount, duration: Date.now() - startedAt, phases }
346
- } finally {
347
- if (!options.test) {
348
- 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
+ }
349
847
  }
350
848
  }
351
849
 
352
- return result
850
+ return { title, categories: order }
353
851
  }
354
852
 
355
853
  /**
@@ -783,7 +1281,7 @@ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
783
1281
  function extractMintlifyConfig(body) {
784
1282
  try {
785
1283
  return JSON.parse(body)
786
- } catch {}
1284
+ } catch { }
787
1285
  // Pull out the first balanced `{ ... }` that contains a "navigation" key.
788
1286
  const navIdx = body.indexOf('"navigation"')
789
1287
  if (navIdx === -1) return null
@@ -794,7 +1292,7 @@ function extractMintlifyConfig(body) {
794
1292
  try {
795
1293
  const parsed = JSON.parse(candidate)
796
1294
  if (parsed && parsed.navigation) return parsed
797
- } catch {}
1295
+ } catch { }
798
1296
  }
799
1297
  start = body.lastIndexOf('{', start - 1)
800
1298
  }
@@ -882,20 +1380,95 @@ function parseMintlifyConfig(config, origin, byPath) {
882
1380
  }
883
1381
 
884
1382
  /**
885
- * Score a parsed nav tree for "sidebar-likeness". A real docs sidebar has
886
- * multiple section headers (hierarchy) and tens of links; secondary navs
887
- * (sitemaps, footers, search-index result lists) tend to be flat single-
888
- * 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.
889
1386
  *
890
- * Flat single-category blocks are penalized so a slightly smaller hierarchical
891
- * block wins over a bigger flat one. Without this, greenflash.ai's sidebar
892
- * order was non-deterministic across runs — sometimes Quickstart first (real
893
- * 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.
894
1389
  */
895
- function scoreNavTree(tree) {
896
- const count = tree.categories.reduce((n, c) => n + c.pages.length, 0)
897
- const cats = tree.categories.length
898
- 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
899
1472
  // Alphabetical penalty: real sidebars are curated (Overview first, related
900
1473
  // topics grouped). Auto-generated indexes, search clouds, and footer link
901
1474
  // lists tend to be strictly alphabetized. When a category's pages come in
@@ -924,7 +1497,7 @@ function isMonotonicAlpha(titles) {
924
1497
  * renders its sidebar server-side as <nav>/<aside> with <h*> section headers.
925
1498
  * Returns null if coverage is too low to be useful.
926
1499
  */
927
- async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1500
+ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostics = {}) {
928
1501
  // Index known pages by normalized pathname so we can match nav hrefs against them.
929
1502
  const byPath = new Map()
930
1503
  for (const p of knownPages) byPath.set(normalizePath(p.url), p)
@@ -1060,7 +1633,10 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1060
1633
  const r0Start = Date.now()
1061
1634
  await visit(sourceUrl)
1062
1635
  const r0Ms = Date.now() - r0Start
1063
- if (categoryOrder.length === 0) return null
1636
+ if (categoryOrder.length === 0) {
1637
+ diagnostics.reason = 'no-categories-round0'
1638
+ return null
1639
+ }
1064
1640
 
1065
1641
  // Round 1 (parallel): visit pages so each branch has a chance to expose
1066
1642
  // its sub-items. Sidebars on most docs sites auto-expand the current
@@ -1077,9 +1653,9 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1077
1653
  const r1Urls = isDiscovery
1078
1654
  ? flattenTree(categoryOrder).slice(0, MAX_DISCOVERY_FETCHES)
1079
1655
  : categoryOrder
1080
- .map((c) => c.pages[0])
1081
- .filter(Boolean)
1082
- .map((p) => toBrowsableUrl(p.url))
1656
+ .map((c) => c.pages[0])
1657
+ .filter(Boolean)
1658
+ .map((p) => toBrowsableUrl(p.url))
1083
1659
  const r1Start = Date.now()
1084
1660
  // Firecrawl standard-plan concurrency is 10; 5 leaves headroom for retries.
1085
1661
  // Native HTTP can run hotter since we're hitting our own loopback.
@@ -1089,14 +1665,44 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey) {
1089
1665
  styles.dim(` ⏱ scrape breakdown: round0=${formatDuration(r0Ms)} round1=${formatDuration(r1Ms)} (${r1Urls.length} ${isDiscovery ? 'discovery' : 'category rep'} fetches)`),
1090
1666
  )
1091
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
+
1092
1686
  // Accept thresholds — looser in discovery mode (no llms.txt) where even a
1093
1687
  // single flat "Overview" bucket is better than nothing, stricter when we
1094
1688
  // have llms.txt to compare against.
1095
1689
  const categories = categoryOrder.filter((c) => c.pages.length > 0)
1096
1690
  if (isDiscovery) {
1097
- 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
+ }
1098
1698
  } else {
1099
- 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
+ }
1100
1706
  }
1101
1707
  return { title: null, categories }
1102
1708
  }
@@ -1121,12 +1727,17 @@ function extractSidebarContainers(html) {
1121
1727
  const SIDEBAR_ATTR_RE =
1122
1728
  /\b(?:id|class|aria-label|data-testid)=(?:"[^"]*sidebar[^"]*"|'[^']*sidebar[^']*'|"[^"]*navigation[^"]*"|'[^']*navigation[^']*')|\brole=(?:"navigation"|'navigation')/i
1123
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
+
1124
1734
  const out = []
1125
1735
  for (const tag of SIDEBAR_TAGS) {
1126
1736
  const openRe = new RegExp(`<${tag}\\b([^>]*)>`, 'gi')
1127
1737
  let m
1128
1738
  while ((m = openRe.exec(html)) !== null) {
1129
1739
  if (!SIDEBAR_ATTR_RE.test(m[1])) continue
1740
+ if (WRAPPER_MODIFIER_RE.test(m[1])) continue
1130
1741
  const inner = extractBalancedTag(html, tag, m.index + m[0].length)
1131
1742
  if (inner != null && inner.length > 100) out.push(inner)
1132
1743
  }
@@ -1337,7 +1948,7 @@ function collectApiReferencePages(orphans, scraped) {
1337
1948
  let segs = []
1338
1949
  try {
1339
1950
  segs = new URL(p.url).pathname.split('/').filter(Boolean)
1340
- } catch {}
1951
+ } catch { }
1341
1952
  // segs[0] is the api-prefix itself; segs[1] (if any) is the resource.
1342
1953
  const resource = segs.length >= 3 ? segs[1] : null
1343
1954
  if (!resource) {
@@ -1393,9 +2004,9 @@ function bucketOrphansByPathType(orphans, scraped) {
1393
2004
  endpoints: 'API Reference',
1394
2005
  endpoint: 'API Reference',
1395
2006
  changelog: 'Changelog',
1396
- release: 'Release Notes',
1397
- releases: 'Release Notes',
1398
- 'release-notes': 'Release Notes',
2007
+ release: 'Changelog',
2008
+ releases: 'Changelog',
2009
+ 'release-notes': 'Changelog',
1399
2010
  recipes: 'Recipes',
1400
2011
  recipe: 'Recipes',
1401
2012
  guides: 'Guides',
@@ -1488,43 +2099,42 @@ function titleCase(s) {
1488
2099
 
1489
2100
  /**
1490
2101
  * Walk all scraped pages (including nested sub-pages) and move any whose URL
1491
- * contains a strong reference segment (`/api-reference/`, `/endpoints/`, etc.)
1492
- * into a single "API Reference" category. Some docs sites (e.g. greenflash.ai)
1493
- * spotlight a handful of endpoints under "Developers" in the sidebar while the
1494
- * bulk of endpoints live under a separate API Reference section — we favor
1495
- * the URL-path signal over the sidebar placement so all reference pages land
1496
- * 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.
1497
2107
  *
1498
- * 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.
1499
2111
  */
1500
- function reclassifyReferencePages(scraped) {
1501
- const REFERENCE_SEGMENT = /^(api[-_]?reference|endpoints?)$/i
2112
+ function reclassifyPagesByUrlSegment(scraped, { segmentRe, categoryRe, defaultTitle }) {
1502
2113
  const normTitle = (t) =>
1503
2114
  String(t || '')
1504
2115
  .replace(INVISIBLE_CHARS, '')
1505
2116
  .trim()
1506
2117
  .toLowerCase()
1507
2118
 
1508
- const looksLikeRefUrl = (url) => {
2119
+ const urlHasSegment = (url) => {
1509
2120
  try {
1510
2121
  const segs = new URL(url).pathname.split('/').filter(Boolean)
1511
- return segs.some((s) => REFERENCE_SEGMENT.test(s))
2122
+ return segs.some((s) => segmentRe.test(s))
1512
2123
  } catch {
1513
2124
  return false
1514
2125
  }
1515
2126
  }
1516
2127
 
1517
- // Find (or create) the canonical API Reference category. Prefer an existing
1518
- // one with a reference-shaped title so we don't end up with duplicates.
1519
- let refCat = scraped.categories.find((c) => /^(api[ -]?reference|reference|api|endpoints?)$/i.test(normTitle(c.title).replace(/\s+/g, ' ')))
1520
- 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, ' ')))
1521
2131
 
1522
2132
  const collected = []
1523
2133
  const filterPages = (pages) => {
1524
2134
  const kept = []
1525
2135
  for (const p of pages || []) {
1526
- if (looksLikeRefUrl(p.url)) {
1527
- // 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.
1528
2138
  collectFlat(p, collected)
1529
2139
  continue
1530
2140
  }
@@ -1534,32 +2144,145 @@ function reclassifyReferencePages(scraped) {
1534
2144
  return kept
1535
2145
  }
1536
2146
 
1537
- // Never pull pages out of the reference category itself.
2147
+ // Never pull pages out of the destination category itself.
1538
2148
  for (const cat of scraped.categories) {
1539
- if (cat === refCat) continue
2149
+ if (cat === destCat) continue
1540
2150
  cat.pages = filterPages(cat.pages)
1541
2151
  }
1542
2152
 
1543
2153
  if (collected.length === 0) return 0
1544
2154
 
1545
- if (!refCat) {
1546
- refCat = { title: 'API Reference', pages: [] }
1547
- scraped.categories.push(refCat)
2155
+ if (!destCat) {
2156
+ destCat = { title: defaultTitle, pages: [] }
2157
+ scraped.categories.push(destCat)
1548
2158
  }
1549
- // Dedupe against anything already in the reference category.
1550
- 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)))
1551
2161
  for (const p of collected) {
1552
2162
  const key = normalizePath(p.url)
1553
2163
  if (seen.has(key)) continue
1554
2164
  seen.add(key)
1555
- 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)
1556
2246
  }
2247
+ return { extracted, kept }
2248
+ }
1557
2249
 
1558
- // Drop now-empty categories (other than the reference one we may have just created).
1559
- 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
+ }
1560
2272
 
1561
- // If the category existed before but the relocation was a no-op, surface 0.
1562
- 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
+ })
1563
2286
  }
1564
2287
 
1565
2288
  function collectFlat(page, out) {
@@ -1573,6 +2296,170 @@ function collectFlat(page, out) {
1573
2296
  }
1574
2297
  }
1575
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
+
2318
+ /**
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`.
2332
+ *
2333
+ * @example
2334
+ * nestByUrlHierarchy([
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' },
2338
+ * ])
2339
+ * // [
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
+ * // ]},
2347
+ * // ]},
2348
+ * // ]
2349
+ */
2350
+ function nestByUrlHierarchy(pages, anchorSegs = null) {
2351
+ if (!pages || pages.length === 0) return pages
2352
+
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.
2355
+ for (const p of pages) {
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
+ }
2365
+ }
2366
+
2367
+ const urlEntries = []
2368
+ const groupOnly = []
2369
+ for (const p of pages) {
2370
+ if (!p.url) {
2371
+ groupOnly.push(p)
2372
+ continue
2373
+ }
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) })
2397
+ }
2398
+ node = node.children.get(segLower)
2399
+ }
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
2425
+ } else {
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]
2449
+ }
2450
+ return outPage
2451
+ }
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]
2461
+ }
2462
+
1576
2463
  /**
1577
2464
  * The scraped nav only contains top-level items; subcategory pages sit behind
1578
2465
  * `>` chevrons and don't render on a cold fetch. For each llms.txt URL not
@@ -1687,6 +2574,62 @@ async function fetchHtmlDirect(url) {
1687
2574
  }
1688
2575
  }
1689
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
+
1690
2633
  /**
1691
2634
  * Firecrawl-backed HTML loader. Firecrawl runs a real browser, waits for
1692
2635
  * hydration, and returns the rendered DOM — which is what we need for sites
@@ -1787,14 +2730,29 @@ function urlNamespace(url) {
1787
2730
  function clusterByUrlPath(pages) {
1788
2731
  if (!pages || pages.length < 3) return null
1789
2732
 
1790
- 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 = []
1791
2744
  try {
1792
- return new URL(p.url).pathname.split('/').filter(Boolean)
1793
- } catch {
1794
- 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)
1795
2752
  }
1796
- })
1797
- 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
1798
2756
 
1799
2757
  // Longest common prefix depth.
1800
2758
  let commonDepth = 0
@@ -1807,13 +2765,13 @@ function clusterByUrlPath(pages) {
1807
2765
  // The segment right after the common base is the category key.
1808
2766
  const keyIdx = commonDepth
1809
2767
  const byKey = new Map()
1810
- for (let i = 0; i < pages.length; i++) {
2768
+ for (let i = 0; i < clusterable.length; i++) {
1811
2769
  const key = parts[i][keyIdx]
1812
2770
  // Skip pages that have no segment at the cluster index (they're AT the
1813
2771
  // common base — those would become their own "index"-like category).
1814
2772
  if (!key) continue
1815
2773
  if (!byKey.has(key)) byKey.set(key, [])
1816
- byKey.get(key).push(pages[i])
2774
+ byKey.get(key).push(clusterable[i])
1817
2775
  }
1818
2776
 
1819
2777
  // Reject weak clusterings: need at least 2 groups AND at least one group
@@ -1824,7 +2782,7 @@ function clusterByUrlPath(pages) {
1824
2782
 
1825
2783
  // Preserve first-appearance order so the sidebar reflects source order.
1826
2784
  const firstSeen = new Map()
1827
- pages.forEach((p, i) => {
2785
+ clusterable.forEach((p, i) => {
1828
2786
  const key = parts[i][keyIdx]
1829
2787
  if (key && !firstSeen.has(key)) firstSeen.set(key, i)
1830
2788
  })
@@ -1835,28 +2793,173 @@ function clusterByUrlPath(pages) {
1835
2793
  pages: byKey.get(key),
1836
2794
  }))
1837
2795
 
1838
- // A cluster with exactly one top-level page is NOT a real category — it's
1839
- // a parent page with children wrapped in a pseudo-category label. Categories
1840
- // are grouping labels with no content of their own; parent pages have
1841
- // content AND children. Collect those singletons into a shared
1842
- // "Documentation" bucket so they're siblings at the top level, each with
1843
- // their own sub-tree intact.
1844
- const multipageClusters = rawClusters.filter((c) => c.pages.length >= 2)
1845
- const singletonPages = rawClusters.filter((c) => c.pages.length === 1).flatMap((c) => c.pages)
1846
-
1847
- const out = []
1848
- if (singletonPages.length > 0) {
1849
- 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])
1850
2811
  }
1851
- out.push(...multipageClusters)
1852
2812
 
1853
- // If we didn't actually produce any multi-page cluster, clustering added no
1854
- // value — every page was a singleton and we'd just have renamed Overview.
1855
- // Tell the caller to stick with the original flat shape.
1856
- 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
+ }
1857
2827
  return out
1858
2828
  }
1859
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
+
1860
2963
  /**
1861
2964
  * Used by discovery-mode scraping (no llms.txt) to decide whether a nav
1862
2965
  * link is worth importing as a doc page. Filters out cross-origin links,
@@ -1877,6 +2980,20 @@ function isDiscoverableLink(abs, base) {
1877
2980
  return true
1878
2981
  }
1879
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
+
1880
2997
  // Zero-width spaces, direction marks, word joiner, BOM — some docs sites
1881
2998
  // inject these into sidebar headings (docs.greenflash.ai prefixes
1882
2999
  // "API Reference" with U+200B), which otherwise defeats downstream
@@ -1916,20 +3033,7 @@ function decodeEntities(s) {
1916
3033
  .replace(/&rdquo;/g, '”')
1917
3034
  }
1918
3035
 
1919
- /**
1920
- * Reduce a URL to a comparable pathname: lowercase host, strip trailing slash
1921
- * and common suffixes (.md, .html) so `/foo/bar.md` and `/foo/bar` match.
1922
- */
1923
- function normalizePath(url) {
1924
- try {
1925
- const u = new URL(url)
1926
- let p = u.pathname.replace(/\/$/, '').toLowerCase()
1927
- p = p.replace(/\.(md|mdx|html?)$/i, '')
1928
- return p
1929
- } catch {
1930
- return String(url).toLowerCase()
1931
- }
1932
- }
3036
+ // normalizePath is imported from ../utils/url-segs.js
1933
3037
 
1934
3038
  /**
1935
3039
  * Given a set of scraped categories + orphan pages that didn't match the nav
@@ -1943,7 +3047,8 @@ async function slotOrphansWithClaude(scraped, orphans, model) {
1943
3047
  categories: scraped.categories,
1944
3048
  orphans,
1945
3049
  })
1946
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3050
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: slotOrphansOutputSchema })
3051
+ const raw = result.assignments
1947
3052
  if (!Array.isArray(raw)) {
1948
3053
  // If the model didn't cooperate, return all orphans unassigned.
1949
3054
  return orphans
@@ -1973,8 +3078,8 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
1973
3078
  const { systemPrompt, userPrompt } = iconizeNavPrompt({
1974
3079
  categories: scraped.categories,
1975
3080
  })
1976
- const icons = await runJsonQuery({ systemPrompt, userPrompt, model })
1977
- 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 : []
1978
3083
  return {
1979
3084
  title: siteTitle || null,
1980
3085
  categories: scraped.categories.map((c, i) => ({
@@ -1986,31 +3091,22 @@ async function iconizeScrapedNav(scraped, _unused, model, siteTitle) {
1986
3091
  }
1987
3092
 
1988
3093
  /**
1989
- * Sections are "usable" when the llms.txt already did the hard grouping work
1990
- * for us — meaningful titles, not too many/few, each populated. When usable we
1991
- * take a fast path that only asks Claude for icons + title polish instead of
1992
- * re-bucketing every page, which is the slow part of a full reorg.
1993
- */
1994
- // Sections named like these are catch-all buckets — even in richly-structured
1995
- // llms.txt files (e.g. Stripe's "Docs" section is where pages go that don't
1996
- // fit into a named product tab), so always drop them rather than promote the
1997
- // grab-bag contents to a top-level sidebar category.
1998
- const GENERIC_SECTION_RE =
1999
- /^(resources?|english|root url|pages?|docs?|documentation|content|available languages.*|site|sitemap|index|home|optional|instructions?(\s|:).*|miscellaneous|misc|other)$/i
2000
-
2001
- /**
2002
- * Return the subset of llms.txt sections that carry real structural signal —
2003
- * drop catch-all buckets ("Docs", "Resources", "Optional"), empty sections,
2004
- * 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.
2005
3099
  */
2006
3100
  function usableSections(sections) {
2007
3101
  if (!sections) return []
2008
- 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)
2009
3103
  }
2010
3104
 
2011
3105
  function sectionsLookUsable(sections) {
2012
- if (!sections || sections.length > 40) return false
2013
- 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
2014
3110
  }
2015
3111
 
2016
3112
  async function organizeWithClaude(parsed, model) {
@@ -2026,16 +3122,17 @@ async function organizeWithClaude(parsed, model) {
2026
3122
  * is O(sections), not O(pages), so this is usually ~5-15s vs. a full reorg.
2027
3123
  */
2028
3124
  async function organizeFromSections(parsed, model) {
2029
- // Drop generic/empty/oversized sections so they don't pollute the sidebar
2030
- // (e.g. Stripe's llms.txt has a "Docs" catch-all and a 0-item "Instructions
2031
- // 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.
2032
3128
  const sections = usableSections(parsed.sections)
2033
3129
 
2034
3130
  const { systemPrompt, userPrompt } = organizeFromSectionsPrompt({
2035
3131
  siteTitle: parsed.title,
2036
3132
  sections,
2037
3133
  })
2038
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3134
+ const result = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromSectionsOutputSchema })
3135
+ const raw = result.sections
2039
3136
  if (!Array.isArray(raw)) {
2040
3137
  throw new Error('Fast-path expected a JSON array of {title, icon} entries.')
2041
3138
  }
@@ -2070,18 +3167,22 @@ async function organizeFromScratch(parsed, model) {
2070
3167
  siteTitle: parsed.title,
2071
3168
  items,
2072
3169
  })
2073
- const raw = await runJsonQuery({ systemPrompt, userPrompt, model })
3170
+ const raw = await runJsonQuery({ systemPrompt, userPrompt, model, schema: organizeFromScratchOutputSchema })
2074
3171
 
2075
3172
  // Rehydrate pages from the id references Claude returned.
2076
3173
  const expandedCategories = []
2077
3174
  const usedIds = new Set()
3175
+ const usedUrls = new Set()
2078
3176
  for (const cat of raw.categories || []) {
2079
3177
  const pages = []
2080
3178
  for (const id of cat.pageIds || []) {
2081
3179
  const item = items[id]
2082
3180
  if (!item) continue // ignore out-of-range ids
2083
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
2084
3184
  usedIds.add(id)
3185
+ usedUrls.add(normUrl)
2085
3186
  pages.push({
2086
3187
  title: item.title,
2087
3188
  url: item.url,
@@ -2111,11 +3212,13 @@ async function organizeFromScratch(parsed, model) {
2111
3212
  }
2112
3213
 
2113
3214
  /**
2114
- * Shared Claude call for "send a prompt, parse JSON back". Logs the prompts so
2115
- * we can see what went in, runs a heartbeat so silent model latency doesn't
2116
- * 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.
2117
3220
  */
2118
- async function runJsonQuery({ systemPrompt, userPrompt, model }) {
3221
+ async function runJsonQuery({ systemPrompt, userPrompt, model, schema }) {
2119
3222
  console.log()
2120
3223
  console.log(styles.dim('─ system prompt ─'))
2121
3224
  console.log(styles.dim(systemPrompt))
@@ -2129,24 +3232,28 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2129
3232
  console.log()
2130
3233
 
2131
3234
  const heartbeat = setInterval(() => process.stdout.write(styles.dim('.')), 1000)
2132
- let text = ''
3235
+ let structured
2133
3236
  try {
2134
3237
  for await (const message of query({
2135
3238
  prompt: userPrompt,
2136
3239
  options: {
2137
3240
  systemPrompt,
2138
3241
  allowedTools: [],
3242
+ outputFormat: { type: 'json_schema', schema },
2139
3243
  ...(model ? { model } : {}),
2140
3244
  },
2141
3245
  })) {
2142
- if (message.type === 'assistant' && message.message?.content) {
2143
- for (const block of message.message.content) {
2144
- 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
+ )
2145
3252
  }
2146
- } else if (message.type === 'result') {
2147
3253
  if (message.subtype && message.subtype !== 'success') {
2148
3254
  throw new Error(`Claude failed: ${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
2149
3255
  }
3256
+ structured = message.structured_output
2150
3257
  break
2151
3258
  }
2152
3259
  }
@@ -2155,18 +3262,10 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2155
3262
  process.stdout.write('\n')
2156
3263
  }
2157
3264
 
2158
- const stripped = stripCodeFences(text)
2159
-
2160
- try {
2161
- return JSON.parse(stripped)
2162
- } catch (e) {
2163
- throw new Error(
2164
- `Claude returned invalid JSON: ${e.message}\n` +
2165
- `Output length: ${stripped.length} chars. Likely hit the model's output limit — try --model sonnet.\n\n` +
2166
- `First 500 chars:\n${stripped.slice(0, 500)}\n\n` +
2167
- `Last 500 chars:\n${stripped.slice(-500)}`,
2168
- )
3265
+ if (!structured || typeof structured !== 'object') {
3266
+ throw new Error('Claude returned no structured output')
2169
3267
  }
3268
+ return structured
2170
3269
  }
2171
3270
 
2172
3271
  /**
@@ -2181,30 +3280,593 @@ async function runJsonQuery({ systemPrompt, userPrompt, model }) {
2181
3280
  *
2182
3281
  * Returns deduped URLs in probe order.
2183
3282
  */
2184
- function buildLlmsCandidates(sourceUrl) {
2185
- const out = []
2186
- const seen = new Set()
2187
- const add = (url) => {
2188
- if (!seen.has(url)) {
2189
- seen.add(url)
2190
- out.push(url)
2191
- }
2192
- }
2193
-
2194
- const origin = sourceUrl.origin
2195
- const segs = sourceUrl.pathname.split('/').filter(Boolean)
2196
- for (let i = segs.length; i >= 0; i--) {
2197
- const prefix = segs.slice(0, i).join('/')
2198
- add(`${origin}${prefix ? '/' + prefix : ''}/llms.txt`)
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
2199
3301
  }
2200
- return out
3302
+ }
3303
+
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
3314
+ }
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 }
3410
+ }
3411
+
3412
+ /**
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.
3419
+ */
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) {
3498
+ try {
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
3509
+ }
3510
+ }
3511
+
3512
+ /**
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 }`.
3516
+ */
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
+ }
3541
+
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
+ }
3554
+
3555
+ styles.info(styles.dim(` no well-known docs route matched — keeping ${sourceUrl.toString()}`))
3556
+ return null
2201
3557
  }
2202
3558
 
2203
3559
  /**
2204
- * Best-effort fetch of a site's /llms.txt. Returns { ok, status, error, parsed }
2205
- * where parsed is { title, sections: [{ title, items: [{ text, url, description }] }] }.
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).
2206
3564
  */
2207
- async function fetchLlmsTxt(llmsUrl) {
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
+ }
3649
+ }
3650
+ }
3651
+ return [...out]
3652
+ }
3653
+
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
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
+ }
3691
+
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
3738
+ }
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)
3769
+ }
3770
+ }
3771
+
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 = {}) {
2208
3870
  try {
2209
3871
  const res = await fetch(llmsUrl, {
2210
3872
  redirect: 'follow',
@@ -2212,63 +3874,201 @@ async function fetchLlmsTxt(llmsUrl) {
2212
3874
  })
2213
3875
  if (!res.ok) return { ok: false, status: res.status }
2214
3876
  const text = await res.text()
2215
- return { ok: true, status: res.status, parsed: parseLlmsTxt(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
+ }
2216
3888
  } catch (e) {
2217
3889
  return { ok: false, error: e.message }
2218
3890
  }
2219
3891
  }
2220
3892
 
3893
+ // Path segments that mark a docs scope. Used to find the right "base" for
3894
+ // filtering sitemap URLs: walking up from the source URL, the first segment
3895
+ // that matches becomes the scope prefix. Hits should stay narrow — `blog`
3896
+ // or `pricing` would over-include marketing pages.
3897
+ const WELL_KNOWN_DOC_SEGMENTS = new Set([
3898
+ 'docs',
3899
+ 'doc',
3900
+ 'documentation',
3901
+ 'guides',
3902
+ 'guide',
3903
+ 'references',
3904
+ 'reference',
3905
+ 'api',
3906
+ 'apis',
3907
+ 'api-reference',
3908
+ 'api-docs',
3909
+ 'learn',
3910
+ 'tutorials',
3911
+ 'tutorial',
3912
+ 'developers',
3913
+ 'developer',
3914
+ ])
3915
+
2221
3916
  /**
2222
- * Parse the llms.txt format. `##` headings become sections;
2223
- * `- [text](url): description` bullets become items. Items before any `##`
2224
- * land in an implicit "Resources" section.
3917
+ * Decide which path prefix to use when filtering sitemap URLs to "the docs
3918
+ * portion of the site." Walk up the source URL's path looking for the first
3919
+ * well-known docs segment (`/docs/`, `/guides/`, `/api-reference/`, …) and
3920
+ * use the prefix up to and including it. Returns null when nothing matches —
3921
+ * in that case we accept any URL on the same origin (no path filter).
2225
3922
  */
2226
- function parseLlmsTxt(body) {
2227
- const lines = body.split(/\r?\n/)
2228
- let title = null
2229
- const sections = []
2230
- let current = null
2231
-
2232
- const itemRe = /^\s*-\s*\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)(?:\s*[:—–-]\s*(.+))?/
3923
+ function deriveSitemapScope(sourceUrl) {
3924
+ const segs = sourceUrl.pathname.split('/').filter(Boolean)
3925
+ for (let i = segs.length - 1; i >= 0; i--) {
3926
+ if (WELL_KNOWN_DOC_SEGMENTS.has(segs[i].toLowerCase())) {
3927
+ return '/' + segs.slice(0, i + 1).join('/') + '/'
3928
+ }
3929
+ }
3930
+ return null
3931
+ }
2233
3932
 
2234
- for (const line of lines) {
2235
- const h1 = line.match(/^#\s+(.+)$/)
2236
- if (h1 && !title) {
2237
- title = h1[1].trim()
2238
- continue
3933
+ /**
3934
+ * Mirror buildLlmsCandidates but for sitemap.xml: walk up the source path
3935
+ * appending `/sitemap.xml` at each level, ending at origin root.
3936
+ */
3937
+ function buildSitemapCandidates(sourceUrl) {
3938
+ const out = []
3939
+ const seen = new Set()
3940
+ const add = (url) => {
3941
+ if (!seen.has(url)) {
3942
+ seen.add(url)
3943
+ out.push(url)
2239
3944
  }
3945
+ }
3946
+ const origin = sourceUrl.origin
3947
+ const segs = sourceUrl.pathname.split('/').filter(Boolean)
3948
+ for (let i = segs.length; i >= 0; i--) {
3949
+ const prefix = segs.slice(0, i).join('/')
3950
+ add(`${origin}${prefix ? '/' + prefix : ''}/sitemap.xml`)
3951
+ }
3952
+ return out
3953
+ }
2240
3954
 
2241
- const h2 = line.match(/^##\s+(.+)$/)
2242
- if (h2) {
2243
- current = { title: h2[1].trim(), items: [] }
2244
- sections.push(current)
2245
- continue
3955
+ /**
3956
+ * Best-effort fetch of a sitemap. Handles both `<urlset>` (a flat list of
3957
+ * page URLs) and `<sitemapindex>` (which references child sitemaps — we
3958
+ * recurse one level, capped at 10 children to avoid runaway fetches).
3959
+ *
3960
+ * `scopePrefix` is a path string ending in `/`, or null. When set, only URLs
3961
+ * whose pathname starts with the prefix and share the sitemap's origin are
3962
+ * returned. Returns { ok, urls } on success.
3963
+ */
3964
+ async function fetchSitemap(sitemapUrl, scopePrefix, depth = 0) {
3965
+ try {
3966
+ const res = await fetch(sitemapUrl, {
3967
+ redirect: 'follow',
3968
+ headers: { 'User-Agent': 'readme-cli-import' },
3969
+ })
3970
+ if (!res.ok) return { ok: false, status: res.status }
3971
+ const text = await res.text()
3972
+ const parsed = parseSitemapXml(text)
3973
+ if (!parsed) return { ok: false, error: 'not a sitemap' }
3974
+
3975
+ if (parsed.kind === 'urlset') {
3976
+ const urls = parsed.locs.filter((u) => urlInScope(u, sitemapUrl, scopePrefix))
3977
+ return { ok: true, urls }
2246
3978
  }
2247
3979
 
2248
- const item = line.match(itemRe)
2249
- if (item) {
2250
- if (!current) {
2251
- current = { title: 'Resources', items: [] }
2252
- sections.push(current)
2253
- }
2254
- current.items.push({
2255
- text: item[1].trim(),
2256
- url: item[2].replace(/[.,;]+$/, ''),
2257
- description: item[3] ? item[3].trim() : null,
2258
- })
3980
+ if (depth >= 1) return { ok: true, urls: [] }
3981
+ const children = parsed.locs.slice(0, 10)
3982
+ const all = []
3983
+ for (const childUrl of children) {
3984
+ const child = await fetchSitemap(childUrl, scopePrefix, depth + 1)
3985
+ if (child.ok) all.push(...child.urls)
2259
3986
  }
3987
+ return { ok: true, urls: all }
3988
+ } catch (e) {
3989
+ return { ok: false, error: e.message }
2260
3990
  }
3991
+ }
2261
3992
 
2262
- return { title, sections }
3993
+ /**
3994
+ * Parse a sitemap with a regex — the format is small and well-defined enough
3995
+ * that a dedicated XML parser would be overkill. Returns
3996
+ * { kind: 'urlset' | 'sitemapindex', locs: string[] } or null when the body
3997
+ * doesn't look like a sitemap (e.g. server returned a 200 HTML 404 page).
3998
+ */
3999
+ function parseSitemapXml(body) {
4000
+ const isIndex = /<sitemapindex[\s>]/i.test(body)
4001
+ const isUrlset = /<urlset[\s>]/i.test(body)
4002
+ if (!isIndex && !isUrlset) return null
4003
+ const locs = []
4004
+ const re = /<loc>\s*([^<]+?)\s*<\/loc>/gi
4005
+ let m
4006
+ while ((m = re.exec(body)) !== null) {
4007
+ const url = decodeXmlEntities(m[1].trim())
4008
+ if (url) locs.push(url)
4009
+ }
4010
+ return { kind: isIndex ? 'sitemapindex' : 'urlset', locs }
4011
+ }
4012
+
4013
+ function decodeXmlEntities(s) {
4014
+ return s
4015
+ .replace(/&amp;/g, '&')
4016
+ .replace(/&lt;/g, '<')
4017
+ .replace(/&gt;/g, '>')
4018
+ .replace(/&quot;/g, '"')
4019
+ .replace(/&apos;/g, "'")
4020
+ }
4021
+
4022
+ function urlInScope(urlStr, sitemapUrl, scopePrefix) {
4023
+ let u
4024
+ try {
4025
+ u = new URL(urlStr)
4026
+ } catch {
4027
+ return false
4028
+ }
4029
+ let sm
4030
+ try {
4031
+ sm = new URL(sitemapUrl)
4032
+ } catch {
4033
+ return false
4034
+ }
4035
+ if (u.origin !== sm.origin) return false
4036
+ if (scopePrefix && !u.pathname.startsWith(scopePrefix)) return false
4037
+ return true
2263
4038
  }
2264
4039
 
2265
4040
  /**
2266
- * Write the organized hierarchy to disk as git-format markdown stubs — just
2267
- * frontmatter, no body yet. docs/ pages go under docs/<Category>/<slug>.md;
2268
- * reference/recipes/custom_pages/custom_blocks get their own top-level dir
2269
- * without a category subfolder (the git-format schema doesn't nest them).
2270
- * Writes _order.yaml per directory so sidebar order matches input order.
4041
+ * Convert a flat sitemap URL list into the `{title, url, description}` shape
4042
+ * the rest of the pipeline expects for knownUrls. Title is derived from the
4043
+ * last non-empty path segment, with file extensions stripped. Index-style
4044
+ * URLs that resolve to the scope root itself are dropped.
2271
4045
  */
4046
+ function sitemapUrlsToKnownUrls(urls) {
4047
+ const out = []
4048
+ const seen = new Set()
4049
+ for (const url of urls) {
4050
+ let pathname
4051
+ try {
4052
+ pathname = new URL(url).pathname
4053
+ } catch {
4054
+ continue
4055
+ }
4056
+ const norm = normalizePath(url)
4057
+ if (seen.has(norm)) continue
4058
+ seen.add(norm)
4059
+ const segs = pathname.split('/').filter(Boolean)
4060
+ const last = segs[segs.length - 1] || ''
4061
+ const slug = last.replace(/\.[a-z0-9]+$/i, '')
4062
+ if (!slug) continue
4063
+ out.push({
4064
+ title: titleCase(slug),
4065
+ url,
4066
+ description: null,
4067
+ })
4068
+ }
4069
+ return out
4070
+ }
4071
+
2272
4072
  /**
2273
4073
  * Recursively print the page tree. Sub-pages are indented under their parent
2274
4074
  * with no leading bullet character, to show them as children of the parent.
@@ -2285,13 +4085,103 @@ function printPagesTree(pages, indentLevel) {
2285
4085
  }
2286
4086
  }
2287
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
+ */
2288
4144
  function stageOrganized(organized, stagingDir, opts = {}) {
2289
4145
  const pickIcon = makeIconPicker()
2290
- const usedSlugs = new Set() // cross-dir: duplicates validator is global
2291
4146
  const byDir = new Map()
2292
4147
  const subDirsByTopDir = new Map()
2293
4148
  const counts = { fileCount: 0, skippedApiRef: 0 }
2294
4149
  const skipApiReference = !!opts.skipApiReference
4150
+ const llmsPaths = opts.llmsPaths || new Set()
4151
+
4152
+ const eligibleCategories = []
4153
+ for (const cat of organized.categories || []) {
4154
+ if (skipApiReference && routeCategory(cat.title).topDir === 'reference') {
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
4166
+ }
4167
+ eligibleCategories.push(cat)
4168
+ }
4169
+
4170
+ // TODO: Remove after readme/gitto handles better
4171
+ for (const cat of eligibleCategories) collapseRedundantLayers(cat)
4172
+
4173
+ // Slug names must be unique
4174
+ const slugFor = ensureUniqueSlugs(eligibleCategories)
4175
+
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))
4182
+ for (const [page, slug] of slugFor) {
4183
+ console.log(`${labelFor(page).padEnd(urlWidth)} → ${slug}`)
4184
+ }
2295
4185
 
2296
4186
  /**
2297
4187
  * Write a page (and its descendants) into `dir`. A page with children gets
@@ -2300,25 +4190,38 @@ function stageOrganized(organized, stagingDir, opts = {}) {
2300
4190
  * This matches git-format's on-disk convention for nested sidebars.
2301
4191
  */
2302
4192
  function writePage(page, dir, topDir, isSubPage = false) {
2303
- const slug = resolveSlug(deriveSlug(page.url, page.title), usedSlugs)
2304
- usedSlugs.add(slug)
4193
+ const slug = slugFor.get(page)
2305
4194
 
2306
4195
  // Group-only nodes (e.g. a resource sub-group within API Reference) have
2307
4196
  // no backing page on the source site — they're pure sidebar containers.
2308
4197
  // Skip the stub write but still recurse so their children land in the
2309
4198
  // right subdirectory.
2310
- const isGroupOnly = !page.url
4199
+ const isEmptyParent = !!page._emptyParent
4200
+ const isGroupOnly = !page.url && !isEmptyParent
2311
4201
 
2312
4202
  if (!isGroupOnly) {
2313
4203
  const relFilePath = `${dir}/${slug}.md`
2314
- // Sub-pages don't get icons per design decision.
2315
- const frontmatter = buildFrontmatter(topDir, page, slug, pickIcon, { skipIcon: isSubPage })
2316
- // x-import points at the source URL for this stub. The content-import
2317
- // step reads it to fetch the page body. x-prefixed custom field is the
2318
- // git-format convention for metadata the schema doesn't know about.
2319
- frontmatter['x-import'] = toBrowsableUrl(page.url)
2320
- // hide pages that need import
2321
- 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
+ }
2322
4225
 
2323
4226
  const absPath = path.join(stagingDir, relFilePath)
2324
4227
  fs.mkdirSync(path.dirname(absPath), { recursive: true })
@@ -2336,12 +4239,8 @@ function stageOrganized(organized, stagingDir, opts = {}) {
2336
4239
  }
2337
4240
  }
2338
4241
 
2339
- for (const cat of organized.categories || []) {
4242
+ for (const cat of eligibleCategories) {
2340
4243
  const { topDir, subDir } = routeCategory(cat.title)
2341
- if (skipApiReference && topDir === 'reference') {
2342
- counts.skippedApiRef += countPagesDeep(cat.pages || [])
2343
- continue
2344
- }
2345
4244
  const dir = subDir ? `${topDir}/${subDir}` : topDir
2346
4245
  if (subDir) {
2347
4246
  if (!subDirsByTopDir.has(topDir)) subDirsByTopDir.set(topDir, [])
@@ -2377,6 +4276,81 @@ function countPagesDeep(pages) {
2377
4276
  return n
2378
4277
  }
2379
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
+
2380
4354
  /**
2381
4355
  * Map a category title to the git-format top-level directory + optional
2382
4356
  * category subdir. docs/ is the only top dir that takes a subfolder.
@@ -2384,6 +4358,9 @@ function countPagesDeep(pages) {
2384
4358
  function routeCategory(title) {
2385
4359
  const t = (title || '').trim()
2386
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' }
2387
4364
  if (/^(recipes?|cookbook|tutorials?|how[ -]?tos?)$/i.test(t)) return { topDir: 'recipes', subDir: null }
2388
4365
  if (/^(custom[ -]?pages?|landing( page)?s?)$/i.test(t)) return { topDir: 'custom_pages', subDir: null }
2389
4366
  if (/^(custom[ -]?blocks?|snippets?|reusable( content)?)$/i.test(t)) return { topDir: 'custom_blocks', subDir: null }
@@ -2420,30 +4397,131 @@ function buildFrontmatter(topDir, page, slug, pickIcon, opts = {}) {
2420
4397
  }
2421
4398
 
2422
4399
  /**
2423
- * Turn a URL's trailing segment into a filename-safe slug. Strips `.md`, kebabs
2424
- * the result, drops any leading numeric prefix (common in imports).
2425
- */
2426
- function deriveSlug(url, fallbackTitle) {
2427
- let raw = ''
2428
- try {
2429
- const segs = new URL(url).pathname.split('/').filter(Boolean)
2430
- raw = segs[segs.length - 1] || ''
2431
- } catch {}
2432
- raw = raw.replace(/\.(md|mdx|html?)$/i, '').replace(/^\d+[-_.]/, '')
2433
- const slug = kebabCase(raw || fallbackTitle || 'page')
2434
- return slug || 'page'
2435
- }
4400
+ // extractUrlPathSegments is imported from ../utils/url-segs.js
2436
4401
 
2437
4402
  /**
2438
- * If `slug` is already in use anywhere in the staging tree, try `slug-2`,
2439
- * `slug-3`, etc. The duplicates validator flags same-slug collisions across
2440
- * directories, not just within a directory, so uniqueness must be global.
4403
+ * Compute a globally-unique slug for every page in the tree.
4404
+ *
4405
+ * Base slug is the last URL segment (kebab-cased). When two or more pages
4406
+ * share a base, we prepend the next-up URL segment to every member of the
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
+ *
4413
+ * @example
4414
+ * ensureUniqueSlugs([
4415
+ * { title: 'Getting Started', pages: [
4416
+ * { title: 'Overview', url: 'https://acme.dev/getting-started/overview' },
4417
+ * { title: 'Install', url: 'https://acme.dev/getting-started/install' },
4418
+ * ]},
4419
+ * { title: 'Guides', pages: [
4420
+ * { title: 'Overview', url: 'https://acme.dev/guides/overview' },
4421
+ * ]},
4422
+ * ])
4423
+ * // Map {
4424
+ * // <Overview (Getting Started)> => 'getting-started-overview', // expanded (collides)
4425
+ * // <Install> => 'install', // unique, no expansion
4426
+ * // <Overview (Guides)> => 'guides-overview', // expanded (collides)
4427
+ * // }
2441
4428
  */
2442
- function resolveSlug(slug, usedSlugs) {
2443
- if (!usedSlugs.has(slug)) return slug
2444
- let n = 2
2445
- while (usedSlugs.has(`${slug}-${n}`)) n++
2446
- return `${slug}-${n}`
4429
+ export function ensureUniqueSlugs(categories) {
4430
+ const entries = []
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) => {
4449
+ for (const p of 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)
4458
+ }
4459
+ }
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
+ }
4470
+
4471
+ const slugFor = (e) => {
4472
+ if (e.segments.length === 0) return kebabCase(e.fallback || 'page') || 'page'
4473
+ const start = Math.max(0, e.segments.length - e.depth)
4474
+ return kebabCase(e.segments.slice(start).join('-')) || 'page'
4475
+ }
4476
+
4477
+ // Iteratively grow any colliding group by one segment until stable. Each
4478
+ // pass only grows pages that still have headroom (depth < segments.length);
4479
+ // a page stuck at full depth stays put while its peers keep expanding.
4480
+ while (true) {
4481
+ const groups = new Map()
4482
+ for (const e of entries) {
4483
+ const s = slugFor(e)
4484
+ if (!groups.has(s)) groups.set(s, [])
4485
+ groups.get(s).push(e)
4486
+ }
4487
+ let grew = false
4488
+ for (const group of groups.values()) {
4489
+ if (group.length < 2) continue
4490
+ for (const e of group) {
4491
+ if (e.depth < e.segments.length) {
4492
+ e.depth++
4493
+ grew = true
4494
+ }
4495
+ }
4496
+ }
4497
+ if (!grew) break
4498
+ }
4499
+
4500
+ const describe = (x) => `"${x.page.title || '(untitled)'}"${x.page.url ? ` <${x.page.url}>` : ' (group-only)'}`
4501
+ const used = new Set()
4502
+ const result = new Map()
4503
+ for (const e of entries) {
4504
+ const base = slugFor(e)
4505
+ let slug = base
4506
+ if (used.has(slug)) {
4507
+ let n = 2
4508
+ while (used.has(`${slug}-${n}`)) n++
4509
+ slug = `${base}-${n}`
4510
+ styles.error(
4511
+ `Slug collision after segment expansion: ${base} — falling back to ${slug} for ${describe(e)}. ` +
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.`,
4514
+ )
4515
+ }
4516
+ used.add(slug)
4517
+ result.set(e.page, slug)
4518
+ }
4519
+ return result
4520
+ }
4521
+
4522
+ const GENERIC_DOCS_SEGMENTS = new Set(['doc', 'docs', 'documentation', 'documentations'])
4523
+ function isGenericDocsSegment(segment) {
4524
+ return GENERIC_DOCS_SEGMENTS.has(kebabCase(segment))
2447
4525
  }
2448
4526
 
2449
4527
  // Values YAML interprets as non-strings need quoting when used as _order entries.
@@ -2554,6 +4632,8 @@ function makeIconPicker() {
2554
4632
  }
2555
4633
  }
2556
4634
 
4635
+ export const __test__ = { discoverLlmsTxt, mergeValidHits, resolveRedirectedSourceUrl }
4636
+
2557
4637
  function formatDuration(ms) {
2558
4638
  const safe = Math.max(0, ms)
2559
4639
  // Show ms under a second so sub-second work doesn't misleadingly read as "0m 0s".