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