@readme/cli 0.0.29 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,6 +62,27 @@ const SUBDOMAIN_ONLY_DOC_ROUTES = new Set(['platform'])
62
62
  // Max concurrent HTTP probes when checking whether a section's landing URL
63
63
  // (e.g. /docs/cli) really exists before importing it as a real overview page.
64
64
  const LANDING_PROBE_CAP = 8
65
+ // Well-known spec locations probed on every import. Tried under the
66
+ // post-adoption origin, the pre-adoption origin when the docs-route probe
67
+ // switched hosts, and (first three only) under a path-kind docs base.
68
+ const WELL_KNOWN_OAS_PATHS = [
69
+ 'openapi.json',
70
+ 'openapi.yaml',
71
+ 'openapi.yml',
72
+ 'swagger.json',
73
+ 'swagger.yaml',
74
+ '.well-known/openapi.json',
75
+ '.well-known/openapi.yaml',
76
+ 'api/openapi.json',
77
+ 'docs/openapi.json',
78
+ 'v3/api-docs',
79
+ 'api-docs',
80
+ 'swagger/v1/swagger.json',
81
+ ]
82
+ // Per-source cap on candidate specs. Candidates are sorted by discovery-source
83
+ // confidence first, so the cap trims the least trusted ones.
84
+ const MAX_OAS_CANDIDATES_PER_SOURCE = 10
85
+ const OAS_SOURCE_CONFIDENCE = ['llms', 'mintlify', 'probe', 'html', 'sitemap', 'link-sweep']
65
86
 
66
87
  /**
67
88
  * Run the importer programmatically. Mirrors the CLI command but throws on
@@ -161,8 +182,12 @@ export async function importDocs(options) {
161
182
  debugSnapshots['05-organized.json'] = organized
162
183
  const debugDir = path.join(os.tmpdir(), `readme-import-debug-${hostnameJoined}-${Date.now()}`)
163
184
  fs.mkdirSync(debugDir, { recursive: true })
185
+ // Probe-hit candidates carry prefetched spec bytes — summarize instead of
186
+ // dumping megabytes of serialized Buffer into the snapshot.
187
+ const bufferSummary = (key, value) =>
188
+ value && value.type === 'Buffer' && Array.isArray(value.data) ? `<Buffer ${value.data.length} bytes>` : value
164
189
  for (const [name, data] of Object.entries(debugSnapshots)) {
165
- fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, null, 2))
190
+ fs.writeFileSync(path.join(debugDir, name), JSON.stringify(data, bufferSummary, 2))
166
191
  }
167
192
  styles.info(`${styles.dim(`Debug snapshots → ${debugDir}`)}`)
168
193
  }
@@ -210,7 +235,7 @@ export async function importDocs(options) {
210
235
  styles.info(`Skipped ${styles.bold(String(staged.skippedApiRef))} API reference page${staged.skippedApiRef === 1 ? '' : 's'} (--skip-api-reference)`)
211
236
  }
212
237
 
213
- // Download captured `.json` specs into <stagingDir>/oas/. Raw bytes only —
238
+ // Download captured candidate specs into <stagingDir>/oas/. Raw bytes only —
214
239
  // validation + OpenAPI detection + upload all happen in the runner. Staged
215
240
  // here (not in docs/ or reference/) so it rides the zip but is invisible to
216
241
  // the docs build. Non-fatal: a failed download is logged and skipped.
@@ -270,6 +295,7 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
270
295
 
271
296
  // Probe well-known docs routes (docs./developer. subdomains and /docs/,
272
297
  // … path prefixes) and adopt the first that resolves as the real base, so everything downstream runs
298
+ const preAdoptionOrigin = sourceUrl.origin
273
299
  const docsBase = await timePhase('probe well-known docs routes', () => resolveDocsBaseUrl(sourceUrl))
274
300
  if (docsBase) {
275
301
  styles.info(
@@ -278,6 +304,10 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
278
304
  sourceUrl = docsBase.url
279
305
  }
280
306
 
307
+ // Fired here so the spec-path probe fully overlaps llms/scrape/organize;
308
+ // awaited just before the candidates are attached to `organized`.
309
+ const oasProbePromise = probeWellKnownOasPaths(sourceUrl, docsBase, preAdoptionOrigin).catch(() => [])
310
+
281
311
  // Discover llms.txt files by BFS walking parents AND children. Seed the
282
312
  // frontier with the walk-up paths (source → root). Any "hit" (file exists
283
313
  // with at least one link row) expands the frontier: parent path + distinct
@@ -303,22 +333,33 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
303
333
  let llmsUrl = llms?.llmsUrl || null
304
334
  const skippedLlms = discovery.skipped
305
335
 
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 = []
336
+ // Capture spec-shaped links (.json/.yaml/.yml — candidate OpenAPI specs)
337
+ // BEFORE anything else sees the parsed tree. They're removed in place here,
338
+ // downloaded into the staged `oas/` dir by importDocs, and validated +
339
+ // uploaded by the runner. Stub generation never sees them.
340
+ const oasCandidates = []
341
+ const seenOasCandidateUrls = new Set()
342
+ const addOasCandidates = (items) => {
343
+ for (const item of items || []) {
344
+ if (!item || !item.url) continue
345
+ const url = item.url.split('#')[0]
346
+ if (seenOasCandidateUrls.has(url)) continue
347
+ seenOasCandidateUrls.add(url)
348
+ oasCandidates.push({ ...item, url })
349
+ }
350
+ }
311
351
  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/.`))
352
+ const llmsSpecUrls = extractOasSpecUrlsFromParsed(llms.parsed)
353
+ if (llmsSpecUrls.length > 0) {
354
+ styles.info(styles.dim(`Captured ${llmsSpecUrls.length} spec link${llmsSpecUrls.length === 1 ? '' : 's'} as candidate OpenAPI spec${llmsSpecUrls.length === 1 ? '' : 's'} → oas/.`))
355
+ addOasCandidates(llmsSpecUrls)
315
356
  }
316
357
  }
317
358
 
318
- // Drop the remaining asset/meta items (llms-full.txt, .yaml specs, …) from
359
+ // Drop the remaining asset/meta items (llms-full.txt, .xml, …) from
319
360
  // the merged parsed result up-front, so every downstream consumer — knownUrls
320
361
  // AND the "use llms.txt sections directly" organize path — sees a clean URL
321
- // list. `.json` items were already pulled out above.
362
+ // list. Spec-shaped items were already pulled out above.
322
363
  if (llms) {
323
364
  const dropped = dropAssetItemsFromParsed(llms.parsed)
324
365
  if (dropped > 0) {
@@ -380,7 +421,9 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
380
421
  console.log()
381
422
  if (sitemapResult) {
382
423
  sitemapUrl = sitemapResult.url
383
- sitemapKnownUrls = sitemapUrlsToKnownUrls(sitemapResult.urls)
424
+ const sitemapOasSink = []
425
+ sitemapKnownUrls = sitemapUrlsToKnownUrls(sitemapResult.urls, sitemapOasSink)
426
+ addOasCandidates(sitemapOasSink)
384
427
  styles.ok(`Found sitemap.xml at ${styles.bold(sitemapUrl)} — ${styles.bold(String(sitemapKnownUrls.length))} URL${sitemapKnownUrls.length === 1 ? '' : 's'} in scope.`)
385
428
  } else {
386
429
  styles.warning(`No llms.txt or sitemap.xml found — falling back to sidebar discovery via scrape.`)
@@ -489,7 +532,9 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
489
532
  // HTML parsing, so try it before falling back to generic nav scraping.
490
533
  styles.info(`Probing for Mintlify config (docs.json, mint.json)...`)
491
534
  const mintlifyStart = Date.now()
492
- const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey))
535
+ const mintlifyOasSink = []
536
+ const mintlifyNav = await timePhase('mintlify probe', () => tryMintlifyNav(sourceUrl.toString(), knownUrls, firecrawlKey, mintlifyOasSink))
537
+ addOasCandidates(mintlifyOasSink)
493
538
  if (debugSnapshots) {
494
539
  debugSnapshots[`02a-mintlify-nav${dbgSuffix}.json`] = mintlifyNav ? JSON.parse(JSON.stringify(mintlifyNav)) : null
495
540
  }
@@ -501,14 +546,36 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
501
546
  }
502
547
  console.log()
503
548
 
549
+ // Fern sites ship the canonical sidebar in RSC flight chunks on every
550
+ // page — deterministic structure where the Firecrawl scrape usually fails
551
+ // acceptance and llms.txt clustering invents categories.
552
+ let fernNav = null
553
+ if (!mintlifyNav) {
554
+ styles.info(`Probing for Fern nav tree...`)
555
+ const fernStart = Date.now()
556
+ fernNav = await timePhase('fern probe', () => tryFernNav(sourceUrl.toString(), knownUrls))
557
+ if (fernNav) {
558
+ const pageCount = fernNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
559
+ styles.ok(
560
+ `Found Fern nav tree in ${styles.bold(formatDuration(Date.now() - fernStart))} — ${styles.bold(String(fernNav.categories.length))} categor${fernNav.categories.length === 1 ? 'y' : 'ies'}, ${styles.bold(String(pageCount))} page${pageCount === 1 ? '' : 's'}.`,
561
+ )
562
+ }
563
+ console.log()
564
+ }
565
+ if (debugSnapshots) {
566
+ debugSnapshots[`02a2-fern-nav${dbgSuffix}.json`] = fernNav ? JSON.parse(JSON.stringify(fernNav)) : null
567
+ }
568
+
504
569
  // Archbee sites embed the canonical document tree in Next.js page data.
505
570
  // Their llms.txt export can be a flat, shuffled list, so prefer the tree
506
571
  // when present.
507
572
  let archbeeNav = null
508
- if (!mintlifyNav) {
573
+ if (!mintlifyNav && !fernNav) {
509
574
  styles.info(`Probing for Archbee document tree...`)
510
575
  const archbeeStart = Date.now()
511
- archbeeNav = await timePhase('archbee probe', () => tryArchbeeNav(sourceUrl.toString(), knownUrls, firecrawlKey))
576
+ const archbeeOasSink = []
577
+ archbeeNav = await timePhase('archbee probe', () => tryArchbeeNav(sourceUrl.toString(), knownUrls, firecrawlKey, archbeeOasSink))
578
+ addOasCandidates(archbeeOasSink)
512
579
  if (archbeeNav) {
513
580
  const pageCount = archbeeNav.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
514
581
  styles.ok(
@@ -523,12 +590,16 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
523
590
  const scrapeDiagnostics = {}
524
591
  if (mintlifyNav) {
525
592
  scraped = { title: mintlifyNav.title, categories: mintlifyNav.categories }
593
+ } else if (fernNav) {
594
+ scraped = { title: fernNav.title, categories: fernNav.categories }
526
595
  } else if (archbeeNav) {
527
596
  scraped = { title: archbeeNav.title, categories: archbeeNav.categories }
528
597
  } else {
529
598
  styles.info(`Scraping sidebar nav from ${styles.bold(sourceUrl.toString())}${firecrawlKey ? ' ' + styles.dim('(via Firecrawl)') : ''}...`)
530
599
  scrapeStart = Date.now()
531
- scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey, scrapeDiagnostics))
600
+ const scrapeOasSink = []
601
+ scraped = await timePhase('scrape nav', () => scrapeNavFromSite(sourceUrl.toString(), knownUrls, firecrawlKey, scrapeDiagnostics, scrapeOasSink))
602
+ addOasCandidates(scrapeOasSink)
532
603
  if (!scraped) scrapeDiagnostics.reason ??= 'unknown'
533
604
  }
534
605
  if (debugSnapshots) {
@@ -543,13 +614,18 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
543
614
  // node because that's the one /docs page the scrape saw). Discard the
544
615
  // scrape and fall through to the llms.txt path, which uses URL-based
545
616
  // clustering when multiple files were merged.
617
+ // API-reference pages are excluded from the denominator: sidebars routinely
618
+ // omit generated endpoint stubs, and those pages are swept into reference/
619
+ // regardless of nav quality, so they say nothing about the nav's fitness as
620
+ // the import's spine.
546
621
  let scrapeDiscardedForCoverage = false
547
622
  if (scraped && llms && knownUrls.length > 0) {
548
623
  const scrapedPages = scraped.categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
549
- const coverage = scrapedPages / knownUrls.length
624
+ const nonReferenceKnown = knownUrls.filter((p) => !urlIsApiReference(p.url))
625
+ const coverage = nonReferenceKnown.length > 0 ? scrapedPages / nonReferenceKnown.length : 1
550
626
  if (coverage < 0.75) {
551
627
  styles.info(
552
- `Scrape covered ${styles.bold(Math.round(coverage * 100) + '%')} of llms.txt pages (need ≥75%) — discarding scrape and organizing from llms.txt.`,
628
+ `Scrape covered ${styles.bold(Math.round(coverage * 100) + '%')} of llms.txt pages (need ≥75%${nonReferenceKnown.length < knownUrls.length ? `, ${knownUrls.length - nonReferenceKnown.length} api-reference pages excluded` : ''}) — discarding scrape and organizing from llms.txt.`,
553
629
  )
554
630
  scraped = null
555
631
  scrapeDiscardedForCoverage = true
@@ -629,12 +705,14 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
629
705
  // category the sweep pass just built, if any) and nest it by resource
630
706
  // segment so routeCategory() maps the whole thing to ReadMe's
631
707
  // `reference/` top-level dir.
632
- const apiResult = collectApiReferencePages(slotted, scraped)
708
+ const linkSweepOasSink = []
709
+ const apiResult = collectApiReferencePages(slotted, scraped, linkSweepOasSink)
633
710
  const otherOrphans = apiResult.nonApiOrphans
634
711
  if (apiResult.category) scraped.categories.push(apiResult.category)
635
712
 
636
- const buckets = bucketOrphansByPathType(otherOrphans, scraped)
713
+ const buckets = bucketOrphansByPathType(otherOrphans, scraped, linkSweepOasSink)
637
714
  for (const b of buckets) scraped.categories.push(b)
715
+ addOasCandidates(linkSweepOasSink)
638
716
  if (debugSnapshots) {
639
717
  debugSnapshots[`04-after-orphan-buckets${dbgSuffix}.json`] = {
640
718
  apiReferenceCollected: apiResult.category
@@ -801,12 +879,32 @@ async function produceOrganizedForSource(sourceUrl, options, timePhase, debugSna
801
879
  // nestByUrlHierarchy renders it as the real parent of its children.
802
880
  await injectSectionLandingPages(organized, sourceUrl)
803
881
 
882
+ // A Fern tree is the authored sidebar: re-nesting by URL invents parent
883
+ // folders the source site renders flat (a section landing absorbing its
884
+ // path-descendant siblings, e.g. vapi's /server-url under Webhooks). Only
885
+ // the sweep-synthesized categories still nest — their pages were moved in
886
+ // flat and have no authored structure.
887
+ const sweepCategories = new Set(['API Reference', 'Changelog'])
804
888
  for (const cat of organized.categories || []) {
889
+ if (fernNav && !sweepCategories.has(cat.title)) continue
805
890
  cat.pages = nestByUrlHierarchy(cat.pages)
806
891
  }
807
892
 
808
893
  styles.ok(`Organized in ${styles.bold(formatDuration(Date.now() - organizeStart))}.`)
809
- organized.oasJsonUrls = oasJsonUrls
894
+
895
+ addOasCandidates(await oasProbePromise)
896
+ oasCandidates.sort((a, b) => OAS_SOURCE_CONFIDENCE.indexOf(a.source) - OAS_SOURCE_CONFIDENCE.indexOf(b.source))
897
+ const cappedOasCandidates = oasCandidates.slice(0, MAX_OAS_CANDIDATES_PER_SOURCE)
898
+ if (oasCandidates.length > 0) {
899
+ const bySource = new Map()
900
+ for (const c of cappedOasCandidates) bySource.set(c.source, (bySource.get(c.source) || 0) + 1)
901
+ const summary = Array.from(bySource, ([source, n]) => `${n} ${source}`).join(', ')
902
+ const overCap = oasCandidates.length - cappedOasCandidates.length
903
+ styles.info(
904
+ styles.dim(`OAS candidates: ${summary}${overCap > 0 ? `; dropped ${overCap} over the cap of ${MAX_OAS_CANDIDATES_PER_SOURCE}` : ''}.`),
905
+ )
906
+ }
907
+ organized.oasJsonUrls = cappedOasCandidates
810
908
 
811
909
  // Record which page URLs came from llms.txt
812
910
  const llmsPaths = new Set()
@@ -1250,7 +1348,7 @@ function createZip(sourceDir, outputZip) {
1250
1348
  * Returns { source, title, categories } or null if no Mintlify config is
1251
1349
  * found or parseable.
1252
1350
  */
1253
- async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
1351
+ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey, oasSink) {
1254
1352
  const origin = new URL(sourceUrl).origin
1255
1353
  const fetchHtml = firecrawlKey ? makeFirecrawlFetcher(firecrawlKey) : fetchHtmlDirect
1256
1354
 
@@ -1262,7 +1360,9 @@ async function tryMintlifyNav(sourceUrl, knownPages, firecrawlKey) {
1262
1360
  const body = await fetchHtml(configUrl)
1263
1361
  if (!body) continue
1264
1362
  const config = extractMintlifyConfig(body)
1265
- if (!config || !config.navigation) continue
1363
+ if (!config) continue
1364
+ if (oasSink) oasSink.push(...extractMintlifyOasCandidates(config, origin))
1365
+ if (!config.navigation) continue
1266
1366
  const parsed = parseMintlifyConfig(config, origin, byPath)
1267
1367
  if (parsed.categories.length > 0) {
1268
1368
  return { source: configUrl, title: parsed.title, categories: parsed.categories }
@@ -1299,6 +1399,51 @@ function extractMintlifyConfig(body) {
1299
1399
  return null
1300
1400
  }
1301
1401
 
1402
+ /**
1403
+ * Collect candidate spec URLs from a Mintlify config's `openapi` fields —
1404
+ * `string | string[] | { source }` at the top level and on any navigation
1405
+ * node. Relative values resolve against the origin. Pushed before the
1406
+ * navigation guard so a config with `openapi` but unusable nav still yields
1407
+ * specs.
1408
+ */
1409
+ function extractMintlifyOasCandidates(config, origin) {
1410
+ const candidates = []
1411
+ const seen = new Set()
1412
+ const addUrl = (raw) => {
1413
+ if (typeof raw !== 'string' || !raw.trim()) return
1414
+ let abs
1415
+ try {
1416
+ abs = new URL(raw, origin)
1417
+ } catch {
1418
+ return
1419
+ }
1420
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') return
1421
+ const href = abs.toString()
1422
+ if (seen.has(href)) return
1423
+ seen.add(href)
1424
+ candidates.push({ url: href, text: null, source: 'mintlify' })
1425
+ }
1426
+ const addValue = (value) => {
1427
+ if (typeof value === 'string') addUrl(value)
1428
+ else if (Array.isArray(value)) for (const v of value) addValue(v)
1429
+ else if (value && typeof value === 'object') addUrl(value.source)
1430
+ }
1431
+ const NAV_KEYS = ['navigation', 'tabs', 'groups', 'anchors', 'versions', 'languages', 'pages', 'dropdowns']
1432
+ const walk = (node) => {
1433
+ if (Array.isArray(node)) {
1434
+ for (const n of node) walk(n)
1435
+ return
1436
+ }
1437
+ if (!node || typeof node !== 'object') return
1438
+ if (node.openapi !== undefined) addValue(node.openapi)
1439
+ for (const key of NAV_KEYS) {
1440
+ if (node[key] !== undefined) walk(node[key])
1441
+ }
1442
+ }
1443
+ walk(config)
1444
+ return candidates
1445
+ }
1446
+
1302
1447
  function parseMintlifyConfig(config, origin, byPath) {
1303
1448
  const title = config.name || null
1304
1449
  const categories = []
@@ -1387,10 +1532,13 @@ function parseMintlifyConfig(config, origin, byPath) {
1387
1532
  * Returns { title, categories } or null if the page is not Archbee or the
1388
1533
  * embedded tree is unavailable.
1389
1534
  */
1390
- async function tryArchbeeNav(sourceUrl, knownPages, firecrawlKey) {
1535
+ async function tryArchbeeNav(sourceUrl, knownPages, firecrawlKey, oasSink) {
1391
1536
  const origin = new URL(sourceUrl).origin
1392
1537
  const fetchHtml = firecrawlKey ? makeFirecrawlFetcher(firecrawlKey) : fetchHtmlDirect
1393
1538
  const html = await fetchHtml(toBrowsableUrl(sourceUrl))
1539
+ if (html && oasSink) {
1540
+ oasSink.push(...extractOasUrlsFromHtml(html, sourceUrl).map((url) => ({ url, text: null, source: 'html' })))
1541
+ }
1394
1542
  if (!html || !/publicDocsTree|Archbee/i.test(html)) return null
1395
1543
 
1396
1544
  const nextData = extractNextData(html)
@@ -1454,6 +1602,249 @@ function archbeeNodeUrl(origin, urlKey) {
1454
1602
  return `${origin}/${key.replace(/^\/+/, '')}`
1455
1603
  }
1456
1604
 
1605
+ // Max page fetches while filling in non-active Fern tab trees (one per tab).
1606
+ const FERN_TAB_FETCH_CAP = 8
1607
+
1608
+ const FERN_SECTION_NODE_TYPES = new Set(['section', 'apiReference', 'apiPackage'])
1609
+ const FERN_SKIP_NODE_TYPES = new Set(['link', 'changelog', 'tab'])
1610
+
1611
+ /**
1612
+ * Fern-powered docs sites embed the complete nav tree in Next.js RSC flight
1613
+ * chunks (`self.__next_f.push([1,"..."])`) on every page — titles, slugs,
1614
+ * nesting, and order exactly as the live sidebar renders them. Only the
1615
+ * active tab's tree is populated in a given page's payload; other tabs are
1616
+ * listed with a `pointsTo` entry path, so each one costs a single extra
1617
+ * fetch.
1618
+ *
1619
+ * Always fetches directly (never Firecrawl): the flight chunks are only
1620
+ * guaranteed present in the raw SSR HTML, not in a rendered/processed DOM.
1621
+ * A dead entry URL (moved slug) is retried once from a known page, since
1622
+ * Fern soft-404s stale deep links.
1623
+ *
1624
+ * Returns { title, categories } or null if the site is not Fern or the
1625
+ * payload yields no usable tree. Any parse failure returns null so the
1626
+ * probe chain falls through to the existing routes.
1627
+ */
1628
+ async function tryFernNav(sourceUrl, knownPages) {
1629
+ try {
1630
+ const origin = new URL(sourceUrl).origin
1631
+ const looksFern = (html) => /<aside[^>]*id="fern-sidebar"/.test(html) || /<meta[^>]+name="generator"[^>]+content="[^"]*buildwithfern/i.test(html)
1632
+ let entryUrl = toBrowsableUrl(sourceUrl)
1633
+ let entryHtml = await fetchHtmlDirect(entryUrl)
1634
+ if (!looksFern(entryHtml)) {
1635
+ // A stale entry deep link 404s on Fern (the not-found boundary renders
1636
+ // with a truncated tree), so retry once from a known live page.
1637
+ const retry = knownPages.find((p) => normalizePath(p.url) !== normalizePath(sourceUrl))
1638
+ if (!retry) return null
1639
+ entryUrl = toBrowsableUrl(retry.url)
1640
+ entryHtml = await fetchHtmlDirect(entryUrl)
1641
+ if (!looksFern(entryHtml)) return null
1642
+ }
1643
+
1644
+ const byPath = new Map()
1645
+ for (const p of knownPages) byPath.set(normalizePath(p.url), p)
1646
+
1647
+ const ctx = { origin, byPath, seenPaths: new Set() }
1648
+ const categories = []
1649
+ const collect = (html, tabTitle) => {
1650
+ const blob = decodeFernFlightBlob(html)
1651
+ addFernCategories(categories, extractFernNavNodes(blob), { ...ctx, tabTitle })
1652
+ return blob
1653
+ }
1654
+
1655
+ const entryBlob = collect(entryHtml, null)
1656
+ const entryPath = normalizePath(entryUrl)
1657
+ const seenTabTargets = new Set()
1658
+ let tabFetches = 0
1659
+ for (const tab of extractFernTabs(entryBlob)) {
1660
+ if (seenTabTargets.has(tab.pointsTo)) continue
1661
+ seenTabTargets.add(tab.pointsTo)
1662
+ const tabUrl = `${origin}/${tab.pointsTo}`
1663
+ if (normalizePath(tabUrl) === entryPath) continue
1664
+ if (tabFetches >= FERN_TAB_FETCH_CAP) break
1665
+ tabFetches++
1666
+ const tabHtml = await fetchHtmlDirect(tabUrl)
1667
+ if (!tabHtml) continue
1668
+ collect(tabHtml, tab.title)
1669
+ }
1670
+
1671
+ const pageCount = categories.reduce((n, c) => n + countUrlPagesDeep(c.pages), 0)
1672
+ if (pageCount === 0) return null
1673
+ return { title: null, categories }
1674
+ } catch {
1675
+ return null
1676
+ }
1677
+ }
1678
+
1679
+ function decodeFernFlightBlob(html) {
1680
+ const chunks = []
1681
+ const chunkRe = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g
1682
+ let m
1683
+ while ((m = chunkRe.exec(html)) !== null) {
1684
+ try {
1685
+ chunks.push(JSON.parse(`"${m[1]}"`))
1686
+ } catch { }
1687
+ }
1688
+ return chunks.join('')
1689
+ }
1690
+
1691
+ function extractBalancedObject(str, start) {
1692
+ let depth = 0
1693
+ let inString = false
1694
+ for (let i = start; i < str.length; i++) {
1695
+ const ch = str[i]
1696
+ if (inString) {
1697
+ if (ch === '\\') i++
1698
+ else if (ch === '"') inString = false
1699
+ } else if (ch === '"') inString = true
1700
+ else if (ch === '{') depth++
1701
+ else if (ch === '}') {
1702
+ depth--
1703
+ if (depth === 0) return str.slice(start, i + 1)
1704
+ }
1705
+ }
1706
+ return null
1707
+ }
1708
+
1709
+ /**
1710
+ * Pull top-level nav container/section objects out of a decoded flight blob.
1711
+ * The blob as a whole is not JSON — nav nodes are embedded mid-stream — so
1712
+ * each marker hit is balanced-brace extracted and parsed on its own. Matches
1713
+ * that fall inside an already-extracted object are its children and skipped.
1714
+ */
1715
+ function extractFernNavNodes(blob) {
1716
+ const nodes = []
1717
+ const markerRe = /\{"type":"(?:sidebarRoot|sidebarGroup|section|apiReference|apiPackage)"/g
1718
+ let consumedUpTo = -1
1719
+ let m
1720
+ while ((m = markerRe.exec(blob)) !== null) {
1721
+ if (m.index < consumedUpTo) continue
1722
+ const raw = extractBalancedObject(blob, m.index)
1723
+ if (!raw) continue
1724
+ consumedUpTo = m.index + raw.length
1725
+ try {
1726
+ nodes.push(JSON.parse(raw))
1727
+ } catch { }
1728
+ }
1729
+ return nodes
1730
+ }
1731
+
1732
+ function extractFernTabs(blob) {
1733
+ const tabs = []
1734
+ const tabRe = /\{"type":"tab",/g
1735
+ let m
1736
+ while ((m = tabRe.exec(blob)) !== null) {
1737
+ const raw = extractBalancedObject(blob, m.index)
1738
+ if (!raw) continue
1739
+ let tab
1740
+ try {
1741
+ tab = JSON.parse(raw)
1742
+ } catch {
1743
+ continue
1744
+ }
1745
+ const pointsTo = fernField(tab.pointsTo)
1746
+ if (!pointsTo) continue
1747
+ tabs.push({ title: fernField(tab.title), pointsTo })
1748
+ }
1749
+ return tabs
1750
+ }
1751
+
1752
+ // Absent fields serialize as the literal string "$undefined" in flight payloads.
1753
+ function fernField(value) {
1754
+ return typeof value === 'string' && value !== '$undefined' && value !== '' ? value : null
1755
+ }
1756
+
1757
+ function fernPageFromNode(node, ctx) {
1758
+ if (!node || typeof node !== 'object' || node.hidden === true) return null
1759
+ if (FERN_SKIP_NODE_TYPES.has(node.type)) return null
1760
+ const children = Array.isArray(node.children) ? node.children.map((c) => fernPageFromNode(c, ctx)).filter(Boolean) : []
1761
+ if (FERN_SECTION_NODE_TYPES.has(node.type)) {
1762
+ // A section owns a page only when overviewPageId is set — it then renders
1763
+ // at the section's own slug. pointsTo is a navigation alias to the
1764
+ // section's first descendant page, never a page of the section itself.
1765
+ const landing = fernField(node.overviewPageId) ? fernField(node.slug) : null
1766
+ const title = fernField(node.title) || 'Untitled'
1767
+ if (!landing) {
1768
+ if (children.length === 0) return null
1769
+ return { title, url: null, _emptyParent: true, _virtualPathSegs: [kebabCase(title) || 'group'], pages: children }
1770
+ }
1771
+ const landingUrl = `${ctx.origin}/${landing}`
1772
+ const known = ctx.byPath.get(normalizePath(landingUrl))
1773
+ return {
1774
+ title,
1775
+ url: known?.url || landingUrl,
1776
+ ...(children.length > 0 ? { pages: children } : {}),
1777
+ }
1778
+ }
1779
+ const slug = fernField(node.slug)
1780
+ if (slug) {
1781
+ const url = `${ctx.origin}/${slug}`
1782
+ const known = ctx.byPath.get(normalizePath(url))
1783
+ return {
1784
+ // The payload title is the sidebar label as rendered; llms.txt titles
1785
+ // come from page headings, which can differ from the nav.
1786
+ title: fernField(node.title) || known?.title || slug,
1787
+ url: known?.url || url,
1788
+ ...(known?.description ? { description: known.description } : {}),
1789
+ ...(children.length > 0 ? { pages: children } : {}),
1790
+ }
1791
+ }
1792
+ if (children.length === 1) return children[0]
1793
+ if (children.length > 1) return { title: fernField(node.title) || 'Untitled', url: null, pages: children }
1794
+ return null
1795
+ }
1796
+
1797
+ /**
1798
+ * Top-level sections become categories; untitled ones adopt the tab title so
1799
+ * two tabs' unnamed roots don't merge into one "Untitled" category with
1800
+ * colliding group slugs. Loose top-level pages (tabs without sections, e.g. a
1801
+ * two-page MCP tab) collapse into one category named after the tab. Pages
1802
+ * already claimed by an earlier category are dropped — the
1803
+ * payload repeats trees across fetches, and multi-product sites nest the
1804
+ * same API groups under both a product tab and the API-reference tab.
1805
+ */
1806
+ function addFernCategories(categories, nodes, ctx) {
1807
+ const roots = []
1808
+ for (const node of nodes) {
1809
+ if (node.type === 'sidebarRoot' || node.type === 'sidebarGroup') roots.push(...(node.children || []))
1810
+ else roots.push(node)
1811
+ }
1812
+ const loosePages = []
1813
+ for (const root of roots) {
1814
+ if (FERN_SECTION_NODE_TYPES.has(root.type)) {
1815
+ const section = fernPageFromNode(root, ctx)
1816
+ if (!section) continue
1817
+ if (!fernField(root.title) && ctx.tabTitle) section.title = ctx.tabTitle
1818
+ const pages = section.pages || []
1819
+ if (section.url && !pages.some((p) => p.url && normalizePath(p.url) === normalizePath(section.url))) {
1820
+ pages.unshift({ title: section.title, url: section.url })
1821
+ }
1822
+ const deduped = dedupeFernPages(pages, ctx.seenPaths)
1823
+ if (deduped.length > 0) categories.push({ title: section.title, pages: deduped })
1824
+ } else {
1825
+ const page = fernPageFromNode(root, ctx)
1826
+ if (page) loosePages.push(page)
1827
+ }
1828
+ }
1829
+ const deduped = dedupeFernPages(loosePages, ctx.seenPaths)
1830
+ if (deduped.length > 0) categories.push({ title: ctx.tabTitle || 'Documentation', pages: deduped })
1831
+ }
1832
+
1833
+ function dedupeFernPages(pages, seenPaths) {
1834
+ const out = []
1835
+ for (const p of pages) {
1836
+ const urlSeen = p.url ? seenPaths.has(normalizePath(p.url)) : false
1837
+ if (p.url && !urlSeen) seenPaths.add(normalizePath(p.url))
1838
+ const kids = p.pages ? dedupeFernPages(p.pages, seenPaths) : []
1839
+ if (kids.length === 0 && (urlSeen || !p.url)) continue
1840
+ const entry = { ...p, url: urlSeen ? null : p.url }
1841
+ if (kids.length > 0) entry.pages = kids
1842
+ else delete entry.pages
1843
+ out.push(entry)
1844
+ }
1845
+ return out
1846
+ }
1847
+
1457
1848
  /**
1458
1849
  * Score a parsed nav tree for "sidebar-likeness". A real docs sidebar has
1459
1850
  * multiple section headers (hierarchy) and tens of links; secondary navs
@@ -1488,6 +1879,63 @@ function isMonotonicAlpha(titles) {
1488
1879
  return true
1489
1880
  }
1490
1881
 
1882
+ /**
1883
+ * Scan a rendered docs page for spec URLs embedded by the common API-reference
1884
+ * renderers — Swagger UI, Redoc, Scalar, Stoplight Elements. Returns absolute
1885
+ * http(s) URLs resolved against the page URL. Pure string scanning on HTML we
1886
+ * already fetched — zero extra requests.
1887
+ */
1888
+ function extractOasUrlsFromHtml(html, pageUrl) {
1889
+ const doc = String(html)
1890
+ const raws = []
1891
+
1892
+ // Swagger UI: `SwaggerUIBundle({ url: "…" })` or `urls: [{ url: "…" }, …]`.
1893
+ // Scanning a bounded window after each init call handles both shapes
1894
+ // without catastrophic backtracking.
1895
+ const swaggerInitRe = /SwaggerUI(?:Bundle)?\s*\(/g
1896
+ let m
1897
+ while ((m = swaggerInitRe.exec(doc)) !== null) {
1898
+ const windowText = doc.slice(m.index, m.index + 3000)
1899
+ const urlRe = /["']?\burl["']?\s*:\s*["']([^"']+)["']/g
1900
+ let u
1901
+ while ((u = urlRe.exec(windowText)) !== null) raws.push(u[1])
1902
+ }
1903
+
1904
+ const redocAttrRe = /<redoc\b[^>]*?\bspec-url\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi
1905
+ while ((m = redocAttrRe.exec(doc)) !== null) raws.push(m[1] ?? m[2] ?? m[3])
1906
+ const redocInitRe = /Redoc\.init\s*\(\s*["']([^"']+)["']/g
1907
+ while ((m = redocInitRe.exec(doc)) !== null) raws.push(m[1])
1908
+
1909
+ // Scalar: <script id="api-reference" data-url="…"> — attr-order agnostic.
1910
+ const scriptTagRe = /<script\b[^>]*>/gi
1911
+ while ((m = scriptTagRe.exec(doc)) !== null) {
1912
+ const tag = m[0]
1913
+ if (!/\bid\s*=\s*["']api-reference["']/i.test(tag)) continue
1914
+ const u = /\bdata-url\s*=\s*["']([^"']+)["']/i.exec(tag)
1915
+ if (u) raws.push(u[1])
1916
+ }
1917
+
1918
+ const stoplightRe = /apiDescriptionUrl\s*=\s*"([^"]+)"/g
1919
+ while ((m = stoplightRe.exec(doc)) !== null) raws.push(m[1])
1920
+
1921
+ const out = []
1922
+ const seen = new Set()
1923
+ for (const raw of raws) {
1924
+ let abs
1925
+ try {
1926
+ abs = new URL(raw, pageUrl)
1927
+ } catch {
1928
+ continue
1929
+ }
1930
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') continue
1931
+ const href = abs.toString()
1932
+ if (seen.has(href)) continue
1933
+ seen.add(href)
1934
+ out.push(href)
1935
+ }
1936
+ return out
1937
+ }
1938
+
1491
1939
  /**
1492
1940
  * Fetch the source URL, find the `<nav>` or `<aside>` that contains the most
1493
1941
  * links matching our known llms.txt URLs, and extract its heading/link
@@ -1497,7 +1945,7 @@ function isMonotonicAlpha(titles) {
1497
1945
  * renders its sidebar server-side as <nav>/<aside> with <h*> section headers.
1498
1946
  * Returns null if coverage is too low to be useful.
1499
1947
  */
1500
- async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostics = {}) {
1948
+ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostics = {}, oasSink) {
1501
1949
  // Index known pages by normalized pathname so we can match nav hrefs against them.
1502
1950
  const byPath = new Map()
1503
1951
  for (const p of knownPages) byPath.set(normalizePath(p.url), p)
@@ -1521,6 +1969,9 @@ async function scrapeNavFromSite(sourceUrl, knownPages, firecrawlKey, diagnostic
1521
1969
 
1522
1970
  const html = await fetchHtml(url)
1523
1971
  if (!html) return 0
1972
+ if (oasSink) {
1973
+ oasSink.push(...extractOasUrlsFromHtml(html, url).map((u) => ({ url: u, text: null, source: 'html' })))
1974
+ }
1524
1975
 
1525
1976
  const base = new URL(url)
1526
1977
  let best = { score: -Infinity, count: 0, tree: null }
@@ -1887,7 +2338,7 @@ function parseNavBlock(blockHtml, base, byPath) {
1887
2338
  *
1888
2339
  * Returns { category, nonApiOrphans, mergedScrapedTitles }.
1889
2340
  */
1890
- function collectApiReferencePages(orphans, scraped) {
2341
+ function collectApiReferencePages(orphans, scraped, oasSink) {
1891
2342
  const API_PREFIX_RE = /^\/(api[-_]?reference|api|reference)(\/|$)/i
1892
2343
  const isApiUrl = (url) => {
1893
2344
  try {
@@ -1903,9 +2354,12 @@ function collectApiReferencePages(orphans, scraped) {
1903
2354
  const seenUrls = new Set()
1904
2355
  const push = (p) => {
1905
2356
  if (!p || !p.url) return
1906
- // Skip non-page assets like /api-reference/openapi.json that some
1907
- // llms.txt files list alongside real pages.
1908
- if (/\.(json|yaml|yml)$/i.test(p.url)) return
2357
+ // Non-page assets like /api-reference/openapi.json that some llms.txt
2358
+ // files list alongside real pages become spec candidates, not doc stubs.
2359
+ if (/\.(json|ya?ml)$/i.test(p.url)) {
2360
+ if (oasSink) oasSink.push({ url: p.url, text: p.title || null, source: 'link-sweep' })
2361
+ return
2362
+ }
1909
2363
  if (seenUrls.has(p.url)) return
1910
2364
  seenUrls.add(p.url)
1911
2365
  apiPages.push(p)
@@ -1995,7 +2449,7 @@ function collectApiReferencePages(orphans, scraped) {
1995
2449
  * Uses distinctive titles — just "Docs" or "Reference" — rather than the raw
1996
2450
  * segment name. Falls back to "Other" when the path has no usable type.
1997
2451
  */
1998
- function bucketOrphansByPathType(orphans, scraped) {
2452
+ function bucketOrphansByPathType(orphans, scraped, oasSink) {
1999
2453
  const TYPE_TITLES = {
2000
2454
  reference: 'API Reference',
2001
2455
  api: 'API Reference',
@@ -2043,9 +2497,12 @@ function bucketOrphansByPathType(orphans, scraped) {
2043
2497
  continue
2044
2498
  }
2045
2499
  const pathname = url.pathname
2046
- // Skip OAS spec endpoints — `/api-reference/openapi.json` etc. aren't
2047
- // documentation pages and shouldn't become stubs.
2048
- if (/\.(json|ya?ml)$/i.test(pathname)) continue
2500
+ // OAS spec endpoints — `/api-reference/openapi.json` etc. aren't
2501
+ // documentation pages; capture them as spec candidates instead of stubs.
2502
+ if (/\.(json|ya?ml)$/i.test(pathname)) {
2503
+ if (oasSink) oasSink.push({ url: p.url, text: p.title || null, source: 'link-sweep' })
2504
+ continue
2505
+ }
2049
2506
 
2050
2507
  const segs = pathname.split('/').filter(Boolean)
2051
2508
  let type = null
@@ -2178,9 +2635,20 @@ function reclassifyPagesByUrlSegment(scraped, { segmentRe, categoryRe, defaultTi
2178
2635
  * live under a separate API Reference section — this lands them all in
2179
2636
  * `reference/` after staging. Returns the number of pages relocated.
2180
2637
  */
2638
+ const API_REFERENCE_URL_SEGMENT_RE = /^(api[-_]?reference|endpoints?)$/i
2639
+
2640
+ function urlIsApiReference(url) {
2641
+ try {
2642
+ const segs = new URL(url).pathname.split('/').filter(Boolean)
2643
+ return segs.some((s) => API_REFERENCE_URL_SEGMENT_RE.test(s))
2644
+ } catch {
2645
+ return false
2646
+ }
2647
+ }
2648
+
2181
2649
  function reclassifyReferencePages(scraped) {
2182
2650
  return reclassifyPagesByUrlSegment(scraped, {
2183
- segmentRe: /^(api[-_]?reference|endpoints?)$/i,
2651
+ segmentRe: API_REFERENCE_URL_SEGMENT_RE,
2184
2652
  categoryRe: /^(api[ -]?reference|reference|api|endpoints?)$/i,
2185
2653
  defaultTitle: 'API Reference',
2186
2654
  })
@@ -2471,10 +2939,10 @@ function slotOrphansByPath(scraped, knownPages) {
2471
2939
  const matched = new Set()
2472
2940
  const pathToCategory = new Map() // normalizedPath → category
2473
2941
  for (const cat of scraped.categories) {
2474
- for (const p of cat.pages) {
2942
+ for (const p of collectUrlPagesDeep(cat.pages)) {
2475
2943
  const norm = normalizePath(p.url)
2476
2944
  matched.add(norm)
2477
- pathToCategory.set(norm, cat)
2945
+ if (!pathToCategory.has(norm)) pathToCategory.set(norm, cat)
2478
2946
  }
2479
2947
  }
2480
2948
 
@@ -2575,14 +3043,17 @@ async function fetchHtmlDirect(url) {
2575
3043
  }
2576
3044
 
2577
3045
  /**
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.
3046
+ * Download a candidate spec URL as raw bytes. Returns a Buffer, or null on
3047
+ * non-OK / network error. No parsing or validation — the runner is the OAS gate.
2580
3048
  */
2581
- async function fetchJsonSpec(url) {
3049
+ async function fetchSpecBytes(url) {
2582
3050
  try {
2583
3051
  const res = await fetch(url, {
2584
3052
  redirect: 'follow',
2585
- headers: { 'User-Agent': 'readme-cli-import', Accept: 'application/json' },
3053
+ headers: {
3054
+ 'User-Agent': 'readme-cli-import',
3055
+ Accept: 'application/json, application/x-yaml, application/yaml, text/yaml, text/plain',
3056
+ },
2586
3057
  })
2587
3058
  if (!res.ok) return null
2588
3059
  return Buffer.from(await res.arrayBuffer())
@@ -2591,36 +3062,55 @@ async function fetchJsonSpec(url) {
2591
3062
  }
2592
3063
  }
2593
3064
 
3065
+ // Extension for an extensionless candidate (e.g. /v3/api-docs), sniffed from
3066
+ // the body. Defaults to .json — the runner tolerates a mislabeled extension.
3067
+ function sniffSpecExtension(text) {
3068
+ try {
3069
+ JSON.parse(text)
3070
+ return '.json'
3071
+ } catch {
3072
+ return OAS_TOP_LEVEL_KEY_RE.test(String(text).slice(0, 64 * 1024)) ? '.yaml' : '.json'
3073
+ }
3074
+ }
3075
+
2594
3076
  /**
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.
3077
+ * Download the captured candidate-spec URLs into `<stagingDir>/oas/`. Probe
3078
+ * hits carry their prefetched bytes in `item.body`; everything else is
3079
+ * fetched here. Bodies that look like HTML are skipped so the runner's
3080
+ * user-visible candidate count stays honest — all other validation happens in
3081
+ * the runner. Filenames come from the URL basename, keeping a .json/.yaml/.yml
3082
+ * extension when present and sniffing one otherwise (collisions get a numeric
3083
+ * suffix); the runner de-dups by content, so identical bytes under different
3084
+ * names are handled there. Returns the count of files written.
2599
3085
  */
2600
3086
  async function downloadOasSpecs(oasJsonUrls, stagingDir) {
2601
3087
  const oasDir = path.join(stagingDir, 'oas')
2602
3088
  fs.mkdirSync(oasDir, { recursive: true })
2603
3089
  const usedNames = new Set()
2604
3090
  let written = 0
2605
- for (const { url } of oasJsonUrls) {
2606
- const buf = await fetchJsonSpec(url)
3091
+ for (const item of oasJsonUrls) {
3092
+ const buf = item.body || (await fetchSpecBytes(item.url))
2607
3093
  if (!buf) {
2608
- styles.info(styles.dim(` oas: skipped ${url} (download failed)`))
3094
+ styles.info(styles.dim(` oas: skipped ${item.url} (download failed)`))
3095
+ continue
3096
+ }
3097
+ const text = buf.toString('utf8')
3098
+ if (/^\s*</.test(text)) {
3099
+ styles.info(styles.dim(` oas: skipped ${item.url} (looks like HTML)`))
2609
3100
  continue
2610
3101
  }
2611
- // Derive a safe, unique *.json filename from the URL basename.
2612
3102
  let base
2613
3103
  try {
2614
- base = path.basename(new URL(url).pathname) || 'openapi.json'
3104
+ base = path.basename(new URL(item.url).pathname) || 'openapi'
2615
3105
  } catch {
2616
- base = 'openapi.json'
3106
+ base = 'openapi'
2617
3107
  }
2618
3108
  base = base.replace(/[^a-zA-Z0-9._-]/g, '-')
2619
- if (!/\.json$/i.test(base)) base += '.json'
3109
+ if (!/\.(json|ya?ml)$/i.test(base)) base += sniffSpecExtension(text)
2620
3110
  let name = base
2621
3111
  let n = 2
2622
3112
  while (usedNames.has(name)) {
2623
- name = base.replace(/\.json$/i, `-${n}.json`)
3113
+ name = base.replace(/\.(json|ya?ml)$/i, `-${n}$&`)
2624
3114
  n++
2625
3115
  }
2626
3116
  usedNames.add(name)
@@ -3286,10 +3776,10 @@ async function runJsonQuery({ systemPrompt, userPrompt, model, schema }) {
3286
3776
  // - llms.txt, llms-full.txt, llms-ctx.txt, … (other llms.txt variants
3287
3777
  // that point back at the same index/dump, not real pages)
3288
3778
  //
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.
3779
+ // `.json`/`.yaml`/`.yml` spec links are captured up-front by
3780
+ // extractOasSpecUrlsFromParsed (candidate OpenAPI specs we download into oas/
3781
+ // and upload separately) and removed from the parsed tree before this drop
3782
+ // runs, so spec-shaped items never reach here — `.yaml` below is a backstop.
3293
3783
  const ASSET_EXT_RE = /\.(ya?ml|xml|toml)$/i
3294
3784
  const LLMS_TXT_RE = /(?:^|\/)llms[^/]*\.txt$/i
3295
3785
  function isAssetOrMetaUrl(url) {
@@ -3301,14 +3791,13 @@ function isAssetOrMetaUrl(url) {
3301
3791
  }
3302
3792
  }
3303
3793
 
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) {
3794
+ // A llms.txt item whose URL points at a spec-shaped file (.json/.yaml/.yml).
3795
+ // These are pulled out of the parsed tree before organization (so stub
3796
+ // generation never sees them) and downloaded into the staged `oas/` dir; the
3797
+ // runner validates each as OpenAPI and uploads the valid ones.
3798
+ function isOasSpecUrl(url) {
3310
3799
  try {
3311
- return /\.json$/i.test(new URL(url).pathname)
3800
+ return /\.(json|ya?ml)$/i.test(new URL(url).pathname)
3312
3801
  } catch {
3313
3802
  return false
3314
3803
  }
@@ -3410,21 +3899,22 @@ function narrowToDocsSubtreeIfNeeded(llms, sourceUrl, hits) {
3410
3899
  }
3411
3900
 
3412
3901
  /**
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.
3902
+ * Pull spec-shaped items (.json/.yaml/.yml) out of a parsed llms.txt structure
3903
+ * IN PLACE, returning the removed items as `[{ url, text, source }]`. Sections
3904
+ * emptied by the removal are pruned too — same shape as
3905
+ * dropAssetItemsFromParsed. Run BEFORE dropAssetItemsFromParsed / knownUrls /
3906
+ * organize so the captured specs never enter the stub tree (skeleton has zero
3907
+ * visibility of OAS files). The caller downloads the returned URLs into the
3908
+ * staged `oas/` dir.
3419
3909
  */
3420
- function extractOasJsonUrlsFromParsed(parsed) {
3910
+ function extractOasSpecUrlsFromParsed(parsed) {
3421
3911
  const captured = []
3422
3912
  const keptSections = []
3423
3913
  for (const section of parsed.sections) {
3424
3914
  const keptItems = []
3425
3915
  for (const item of section.items) {
3426
- if (isOasJsonUrl(item.url)) {
3427
- captured.push({ url: item.url, text: item.text || null })
3916
+ if (isOasSpecUrl(item.url)) {
3917
+ captured.push({ url: item.url, text: item.text || null, source: 'llms' })
3428
3918
  continue
3429
3919
  }
3430
3920
  keptItems.push(item)
@@ -3489,6 +3979,78 @@ function buildWellKnownDocRoutes(sourceUrl) {
3489
3979
  return out
3490
3980
  }
3491
3981
 
3982
+ // Content gate for spec candidates — many sites 200 with an HTML shell for
3983
+ // any path, so a body only counts as a spec if it parses as JSON with a
3984
+ // top-level `openapi`/`swagger` version string, or matches the YAML form of
3985
+ // that key. (Pattern mirrors ai-cli-runner/app/lib/oas-sniff.ts — copied,
3986
+ // never imported.)
3987
+ const OAS_TOP_LEVEL_KEY_RE = /^\s*['"]?(openapi|swagger)['"]?\s*:/m
3988
+ function sniffOasContent(text) {
3989
+ const body = String(text)
3990
+ if (/^\s*</.test(body)) return false
3991
+ try {
3992
+ const obj = JSON.parse(body)
3993
+ if (!obj || typeof obj !== 'object') return false
3994
+ return typeof obj.openapi === 'string' || typeof obj.swagger === 'string'
3995
+ } catch {
3996
+ return OAS_TOP_LEVEL_KEY_RE.test(body.slice(0, 64 * 1024))
3997
+ }
3998
+ }
3999
+
4000
+ function buildWellKnownOasProbeUrls(sourceUrl, docsBase, preAdoptionOrigin) {
4001
+ const urls = []
4002
+ const seen = new Set()
4003
+ const add = (href) => {
4004
+ if (seen.has(href)) return
4005
+ seen.add(href)
4006
+ urls.push(href)
4007
+ }
4008
+ const origins = [sourceUrl.origin]
4009
+ if (preAdoptionOrigin && preAdoptionOrigin !== sourceUrl.origin) origins.push(preAdoptionOrigin)
4010
+ for (const origin of origins) {
4011
+ for (const p of WELL_KNOWN_OAS_PATHS) add(`${origin}/${p}`)
4012
+ }
4013
+ if (docsBase && docsBase.kind === 'path') {
4014
+ const base = docsBase.url.href.replace(/\/+$/, '')
4015
+ for (const p of ['openapi.json', 'openapi.yaml', 'swagger.json']) add(`${base}/${p}`)
4016
+ }
4017
+ return urls
4018
+ }
4019
+
4020
+ /**
4021
+ * Probe well-known spec paths on the source origins. A hit is an OK response
4022
+ * whose body sniffs as a spec and whose final URL stayed on a probed origin
4023
+ * (redirects to a marketing apex don't count). Returns ALL hits — a site can
4024
+ * serve several specs — with the fetched bytes attached so downloadOasSpecs
4025
+ * never re-downloads them.
4026
+ */
4027
+ async function probeWellKnownOasPaths(sourceUrl, docsBase, preAdoptionOrigin) {
4028
+ const probeUrls = buildWellKnownOasProbeUrls(sourceUrl, docsBase, preAdoptionOrigin)
4029
+ const allowedOrigins = new Set([sourceUrl.origin])
4030
+ if (preAdoptionOrigin) allowedOrigins.add(preAdoptionOrigin)
4031
+ const hits = await Promise.all(
4032
+ probeUrls.map(async (url) => {
4033
+ try {
4034
+ const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4035
+ if (!res.ok) return null
4036
+ let finalOrigin
4037
+ try {
4038
+ finalOrigin = new URL(res.url || url).origin
4039
+ } catch {
4040
+ return null
4041
+ }
4042
+ if (!allowedOrigins.has(finalOrigin)) return null
4043
+ const body = Buffer.from(await res.arrayBuffer())
4044
+ if (!sniffOasContent(body.toString('utf8'))) return null
4045
+ return { url, text: null, source: 'probe', body }
4046
+ } catch {
4047
+ return null
4048
+ }
4049
+ }),
4050
+ )
4051
+ return hits.filter(Boolean)
4052
+ }
4053
+
3492
4054
  /**
3493
4055
  * Follow redirects on the source URL itself and return the final URL when the
3494
4056
  * site has moved (different origin or path), or null to keep the original.
@@ -3539,13 +4101,18 @@ async function resolveDocsBaseUrl(sourceUrl) {
3539
4101
  }
3540
4102
  }
3541
4103
 
4104
+ const soft404Title = candidates.some((c) => c.kind === 'path') ? await fetchSoft404Title(sourceUrl.origin) : null
3542
4105
  const existence = await Promise.all(
3543
4106
  llmsHits.map(async ({ c, res }) => {
3544
4107
  if (res.error && c.kind === 'subdomain') return { c, exists: false }
3545
- return { c, exists: await docsRouteResolves(c) }
4108
+ return { c, ...(await docsRouteResolves(c, soft404Title)) }
3546
4109
  }),
3547
4110
  )
3548
- for (const { c, exists } of existence) {
4111
+ for (const { c, exists, soft404 } of existence) {
4112
+ if (soft404) {
4113
+ styles.info(styles.dim(` ${c.url.href} → indistinguishable from a nonexistent path (SPA catch-all) — skipping`))
4114
+ continue
4115
+ }
3549
4116
  if (exists) {
3550
4117
  styles.info(styles.dim(` ${c.url.href} → resolves (no llms.txt)`))
3551
4118
  return { url: c.url, kind: c.kind, hasLlms: false }
@@ -3570,20 +4137,54 @@ function inCandidateScope(candidate, finalUrl) {
3570
4137
  return false
3571
4138
  }
3572
4139
  if (candidate.kind === 'subdomain') return final.hostname === candidate.url.hostname
3573
- return final.pathname.toLowerCase().startsWith(candidate.url.pathname.toLowerCase())
4140
+ const strip = (p) => p.toLowerCase().replace(/\/+$/, '')
4141
+ const finalPath = strip(final.pathname)
4142
+ const candidatePath = strip(candidate.url.pathname)
4143
+ return finalPath === candidatePath || finalPath.startsWith(`${candidatePath}/`)
4144
+ }
4145
+
4146
+ function extractHtmlTitle(html) {
4147
+ const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
4148
+ if (!match) return null
4149
+ const title = match[1].replace(/\s+/g, ' ').trim().toLowerCase()
4150
+ return title || null
4151
+ }
4152
+
4153
+ /**
4154
+ * Fetch a guaranteed-nonexistent path and capture its page title. On SPA
4155
+ * catch-all sites every unknown path returns 200 with the same shell page;
4156
+ * that title becomes the soft-404 baseline. Returns null when the site 404s
4157
+ * properly (no baseline needed) or on any fetch failure.
4158
+ */
4159
+ async function fetchSoft404Title(origin) {
4160
+ const probeUrl = `${origin}/readme-cli-nonexistent-${Math.random().toString(36).slice(2)}`
4161
+ try {
4162
+ const res = await fetch(probeUrl, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
4163
+ if (!res.ok) return null
4164
+ return extractHtmlTitle(await res.text())
4165
+ } catch {
4166
+ return null
4167
+ }
3574
4168
  }
3575
4169
 
3576
4170
  /**
3577
4171
  * Does a well-known docs route exist and stay in scope? Follows redirects and
3578
4172
  * rejects anything that lands outside the candidate (a `docs.` subdomain that
3579
- * bounces to marketing, a `/docs/` that 302s home). DNS failures → false.
4173
+ * bounces to marketing, a `/docs/` that 302s home). When a soft-404 baseline
4174
+ * title is known, a path candidate serving that same title is a catch-all
4175
+ * response, not real docs. DNS failures → `{ exists: false }`.
3580
4176
  */
3581
- async function docsRouteResolves(candidate) {
4177
+ async function docsRouteResolves(candidate, soft404Title) {
3582
4178
  try {
3583
4179
  const res = await fetch(candidate.url.href, { redirect: 'follow', headers: { 'User-Agent': 'readme-cli-import' } })
3584
- return res.ok && inCandidateScope(candidate, res.url)
4180
+ if (!res.ok || !inCandidateScope(candidate, res.url)) return { exists: false }
4181
+ if (candidate.kind === 'path' && soft404Title) {
4182
+ const title = extractHtmlTitle(await res.text())
4183
+ if (title === soft404Title) return { exists: false, soft404: true }
4184
+ }
4185
+ return { exists: true }
3585
4186
  } catch {
3586
- return false
4187
+ return { exists: false }
3587
4188
  }
3588
4189
  }
3589
4190
 
@@ -4041,9 +4642,10 @@ function urlInScope(urlStr, sitemapUrl, scopePrefix) {
4041
4642
  * Convert a flat sitemap URL list into the `{title, url, description}` shape
4042
4643
  * the rest of the pipeline expects for knownUrls. Title is derived from the
4043
4644
  * last non-empty path segment, with file extensions stripped. Index-style
4044
- * URLs that resolve to the scope root itself are dropped.
4645
+ * URLs that resolve to the scope root itself are dropped. Spec-shaped URLs
4646
+ * (.json/.yaml/.yml) go to `oasSink` instead of becoming pages.
4045
4647
  */
4046
- function sitemapUrlsToKnownUrls(urls) {
4648
+ function sitemapUrlsToKnownUrls(urls, oasSink) {
4047
4649
  const out = []
4048
4650
  const seen = new Set()
4049
4651
  for (const url of urls) {
@@ -4056,6 +4658,10 @@ function sitemapUrlsToKnownUrls(urls) {
4056
4658
  const norm = normalizePath(url)
4057
4659
  if (seen.has(norm)) continue
4058
4660
  seen.add(norm)
4661
+ if (/\.(json|ya?ml)$/i.test(pathname)) {
4662
+ if (oasSink) oasSink.push({ url, text: null, source: 'sitemap' })
4663
+ continue
4664
+ }
4059
4665
  const segs = pathname.split('/').filter(Boolean)
4060
4666
  const last = segs[segs.length - 1] || ''
4061
4667
  const slug = last.replace(/\.[a-z0-9]+$/i, '')
@@ -4185,9 +4791,12 @@ function stageOrganized(organized, stagingDir, opts = {}) {
4185
4791
 
4186
4792
  /**
4187
4793
  * Write a page (and its descendants) into `dir`. A page with children gets
4188
- * its own subfolder named after its slug; the parent page lives at
4189
- * `<dir>/<slug>.md` while children live at `<dir>/<slug>/<childSlug>.md`.
4190
- * This matches git-format's on-disk convention for nested sidebars.
4794
+ * its own subfolder named after its slug; its content lives at
4795
+ * `<dir>/<slug>/index.md` while children live at `<dir>/<slug>/<childSlug>.md`.
4796
+ * A leaf page (no children) lives at `<dir>/<slug>.md`. This matches
4797
+ * git-format's on-disk convention: gitto backs a folder's sidebar node with
4798
+ * its `index.md`; a sibling `<slug>.md` next to the folder instead leaves the
4799
+ * folder a contentless placeholder parent and the sibling a stray page.
4191
4800
  */
4192
4801
  function writePage(page, dir, topDir, isSubPage = false) {
4193
4802
  const slug = slugFor.get(page)
@@ -4198,9 +4807,13 @@ function stageOrganized(organized, stagingDir, opts = {}) {
4198
4807
  // right subdirectory.
4199
4808
  const isEmptyParent = !!page._emptyParent
4200
4809
  const isGroupOnly = !page.url && !isEmptyParent
4810
+ const children = page.pages || []
4811
+ const hasChildren = children.length > 0
4201
4812
 
4202
4813
  if (!isGroupOnly) {
4203
- const relFilePath = `${dir}/${slug}.md`
4814
+ // Parents-with-children store their content INSIDE the folder as
4815
+ // `index.md`; leaf pages stay as `<slug>.md`.
4816
+ const relFilePath = hasChildren ? `${dir}/${slug}/index.md` : `${dir}/${slug}.md`
4204
4817
  let frontmatter
4205
4818
  if (isEmptyParent) {
4206
4819
  // Synthetic parent page: covers a missing URL segment so descendants
@@ -4228,28 +4841,83 @@ function stageOrganized(organized, stagingDir, opts = {}) {
4228
4841
  fs.writeFileSync(absPath, matter.stringify('', frontmatter))
4229
4842
  counts.fileCount++
4230
4843
 
4844
+ // The sidebar entry is `slug` for both leaves (`<slug>.md`) and parents
4845
+ // (the `<slug>/` folder) — `index` is never listed, it's implicit.
4231
4846
  if (!byDir.has(dir)) byDir.set(dir, [])
4232
4847
  byDir.get(dir).push(slug)
4233
4848
  }
4234
4849
 
4235
- const children = page.pages || []
4236
- if (children.length > 0) {
4850
+ if (hasChildren) {
4237
4851
  const subDir = `${dir}/${slug}`
4238
4852
  for (const child of children) writePage(child, subDir, topDir, true)
4239
4853
  }
4240
4854
  }
4241
4855
 
4856
+ // gitto derives a page's slug from the folder BASENAME alone (getSlugFromPath),
4857
+ // so two category folders that share a basename — e.g. docs/Fundamentals and
4858
+ // docs/cockroach/Fundamentals — both slug to `fundamentals` and collide. The
4859
+ // deeper one renders as an unnamed, content-less placeholder parent.
4860
+ // Namespace every NESTED category folder by its ancestor path so its basename
4861
+ // (and thus slug) is globally unique, and back it with an `index.md` so it's a
4862
+ // real titled parent. Top-level categories are matched by title, not slug, so
4863
+ // their folders are left untouched.
4864
+ const usedContainerSlugs = new Set(slugFor.values())
4865
+ const segRename = new Map() // original path prefix -> unique folder name
4866
+ const nestedContainers = new Map() // renamed dir (rel to stagingDir) -> display title
4867
+ const uniqueContainerName = (base) => {
4868
+ let name = base || 'section'
4869
+ if (usedContainerSlugs.has(name)) {
4870
+ let n = 2
4871
+ while (usedContainerSlugs.has(`${name}-${n}`)) n++
4872
+ name = `${name}-${n}`
4873
+ }
4874
+ usedContainerSlugs.add(name)
4875
+ return name
4876
+ }
4877
+ const namespaceSubDir = (topDir, subDir) => {
4878
+ const segs = subDir.split('/').filter(Boolean)
4879
+ const out = []
4880
+ for (let i = 0; i < segs.length; i++) {
4881
+ if (i === 0) {
4882
+ out.push(segs[0])
4883
+ continue
4884
+ }
4885
+ const origPrefix = segs.slice(0, i + 1).join('/')
4886
+ let name = segRename.get(origPrefix)
4887
+ if (!name) {
4888
+ name = uniqueContainerName(kebabCase(segs.slice(0, i + 1).join('-')))
4889
+ segRename.set(origPrefix, name)
4890
+ nestedContainers.set(`${topDir}/${[...out, name].join('/')}`, segs[i])
4891
+ }
4892
+ out.push(name)
4893
+ }
4894
+ return out.join('/')
4895
+ }
4896
+
4242
4897
  for (const cat of eligibleCategories) {
4243
4898
  const { topDir, subDir } = routeCategory(cat.title)
4244
- const dir = subDir ? `${topDir}/${subDir}` : topDir
4245
- if (subDir) {
4899
+ const routedSubDir = subDir ? namespaceSubDir(topDir, subDir) : subDir
4900
+ const dir = routedSubDir ? `${topDir}/${routedSubDir}` : topDir
4901
+ if (routedSubDir) {
4246
4902
  if (!subDirsByTopDir.has(topDir)) subDirsByTopDir.set(topDir, [])
4247
- if (!subDirsByTopDir.get(topDir).includes(subDir)) subDirsByTopDir.get(topDir).push(subDir)
4903
+ if (!subDirsByTopDir.get(topDir).includes(routedSubDir)) subDirsByTopDir.get(topDir).push(routedSubDir)
4248
4904
  }
4249
4905
 
4250
4906
  for (const page of cat.pages || []) writePage(page, dir, topDir, false)
4251
4907
  }
4252
4908
 
4909
+ // Back each namespaced nested-category folder with a titled parent page so
4910
+ // gitto renders it named instead of as an empty placeholder. No x-import —
4911
+ // these are pure nav containers, so the body stays empty and format skips them.
4912
+ for (const [relDir, title] of nestedContainers) {
4913
+ const idxPath = path.join(stagingDir, relDir, 'index.md')
4914
+ if (fs.existsSync(idxPath)) continue
4915
+ fs.mkdirSync(path.dirname(idxPath), { recursive: true })
4916
+ const fm = { title, icon: formatIconClass(pickIcon(path.basename(relDir), title)) }
4917
+ fs.writeFileSync(idxPath, matter.stringify('', fm))
4918
+ counts.fileCount++
4919
+ }
4920
+
4253
4921
  // Per-directory _order.yaml preserves input order in the sidebar.
4254
4922
  for (const [dir, slugs] of byDir) {
4255
4923
  const orderPath = path.join(stagingDir, dir, '_order.yaml')
@@ -4310,6 +4978,10 @@ function relocateChangelogDir(stagingDir) {
4310
4978
  if (!entry.name.endsWith('.md')) continue
4311
4979
  const parsed = matter(fs.readFileSync(full, 'utf-8'))
4312
4980
  if (!parsed.data || !parsed.data['x-import']) continue
4981
+ if (entry.name === 'index.md' && ancestors.length > 0) {
4982
+ pages.push({ ancestors: ancestors.slice(0, -1), slug: ancestors.at(-1), content: parsed.content, data: parsed.data })
4983
+ continue
4984
+ }
4313
4985
  const slug = entry.name.replace(/\.md$/, '')
4314
4986
  pages.push({ ancestors, slug, content: parsed.content, data: parsed.data })
4315
4987
  }
@@ -4632,7 +5304,36 @@ function makeIconPicker() {
4632
5304
  }
4633
5305
  }
4634
5306
 
4635
- export const __test__ = { discoverLlmsTxt, mergeValidHits, resolveRedirectedSourceUrl }
5307
+ export const __test__ = {
5308
+ discoverLlmsTxt,
5309
+ mergeValidHits,
5310
+ resolveRedirectedSourceUrl,
5311
+ resolveDocsBaseUrl,
5312
+ inCandidateScope,
5313
+ tryFernNav,
5314
+ sniffOasContent,
5315
+ buildWellKnownOasProbeUrls,
5316
+ probeWellKnownOasPaths,
5317
+ extractOasUrlsFromHtml,
5318
+ extractMintlifyOasCandidates,
5319
+ sitemapUrlsToKnownUrls,
5320
+ extractOasSpecUrlsFromParsed,
5321
+ downloadOasSpecs,
5322
+ injectSectionLandingPages,
5323
+ parseMetaRefreshTarget,
5324
+ parseHtmlCanonical,
5325
+ isDiscoverableLink,
5326
+ isAuthChromeUrl,
5327
+ isDocsUnderApiPath,
5328
+ filterUrlPagesTree,
5329
+ stageOrganized,
5330
+ urlIsApiReference,
5331
+ urlIsChangelog,
5332
+ reclassifyReferencePages,
5333
+ extractChangelogFromSections,
5334
+ decodeEntities,
5335
+ stripTags,
5336
+ }
4636
5337
 
4637
5338
  function formatDuration(ms) {
4638
5339
  const safe = Math.max(0, ms)