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