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