@readme/cli 0.0.29 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +8 -4
- package/src/cli.js +2 -2
- package/src/commands/import-oas.test.js +277 -0
- package/src/commands/import.classify.test.js +172 -0
- package/src/commands/import.js +793 -92
- package/src/commands/import.landing.test.js +217 -0
- package/src/commands/import.staging.test.js +158 -0
- package/src/commands/import.test.js +246 -0
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-sync.js +797 -59
- package/src/commands/oas-validate.js +23 -0
- package/src/gha-pre.js +29 -0
- package/src/gha.js +104 -0
- package/src/utils/gha-output.js +32 -0
- package/src/utils/lint.js +30 -15
- package/src/utils/llms.test.js +45 -0
- package/src/utils/reporter.js +2 -1
- package/src/utils/slug-for-entry.js +16 -0
- package/src/validators/components.js +10 -7
- package/src/validators/duplicates.js +2 -4
- package/src/validators/links.js +2 -8
- package/src/validators/oas-reference.js +35 -9
package/src/commands/import.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
307
|
-
// the parsed tree. They're removed in place here,
|
|
308
|
-
// `oas/` dir by importDocs, and validated +
|
|
309
|
-
// generation never sees them.
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
if (
|
|
314
|
-
styles.info(styles.dim(`Captured ${
|
|
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, .
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
if (/\.(json|
|
|
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
|
-
//
|
|
2047
|
-
// documentation pages
|
|
2048
|
-
if (/\.(json|ya?ml)$/i.test(pathname))
|
|
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:
|
|
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
|
|
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
|
|
3049
|
+
async function fetchSpecBytes(url) {
|
|
2582
3050
|
try {
|
|
2583
3051
|
const res = await fetch(url, {
|
|
2584
3052
|
redirect: 'follow',
|
|
2585
|
-
headers: {
|
|
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
|
|
2596
|
-
*
|
|
2597
|
-
*
|
|
2598
|
-
*
|
|
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
|
|
2606
|
-
const buf = await
|
|
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
|
|
3104
|
+
base = path.basename(new URL(item.url).pathname) || 'openapi'
|
|
2615
3105
|
} catch {
|
|
2616
|
-
base = 'openapi
|
|
3106
|
+
base = 'openapi'
|
|
2617
3107
|
}
|
|
2618
3108
|
base = base.replace(/[^a-zA-Z0-9._-]/g, '-')
|
|
2619
|
-
if (!/\.json$/i.test(base)) base +=
|
|
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}
|
|
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`
|
|
3290
|
-
//
|
|
3291
|
-
//
|
|
3292
|
-
//
|
|
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
|
|
3305
|
-
// the parsed tree before organization (so stub
|
|
3306
|
-
// downloaded into the staged `oas/` dir; the
|
|
3307
|
-
// and uploads the valid ones.
|
|
3308
|
-
|
|
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
|
|
3414
|
-
* removed items as `[{ url, text }]`. Sections
|
|
3415
|
-
* pruned too — same shape as
|
|
3416
|
-
* dropAssetItemsFromParsed / knownUrls /
|
|
3417
|
-
* enter the stub tree (skeleton has zero
|
|
3418
|
-
* downloads the returned URLs into the
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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;
|
|
4189
|
-
* `<dir>/<slug
|
|
4190
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4245
|
-
|
|
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(
|
|
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__ = {
|
|
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)
|