@readme/cli 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/cli.js +1 -1
- package/src/commands/import.js +2134 -430
- package/src/commands/import.slug-planner.test.js +86 -0
- package/src/commands/import.test.js +136 -0
- package/src/commands/oas-sync.js +60 -75
- package/src/prompts/index.js +101 -13
- package/src/utils/llms.js +224 -0
- package/src/utils/llms.test.js +257 -0
- package/src/utils/url-segs.js +124 -0
- package/src/utils/url-segs.test.js +239 -0
- package/src/validators/components.js +1 -0
- package/src/validators/duplicates.js +6 -4
- package/src/validators/frontmatter.js +11 -0
- package/src/validators/oas-reference.js +1 -31
- package/src/validators/ordering.js +169 -83
- package/vendor/TOOLS.md +1 -1
- package/src/dev/.next/app-build-manifest.json +0 -20
- package/src/dev/.next/build-manifest.json +0 -31
- package/src/dev/.next/cache/.rscinfo +0 -1
- package/src/dev/.next/cache/next-devtools-config.json +0 -1
- package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/package.json +0 -1
- package/src/dev/.next/prerender-manifest.json +0 -11
- package/src/dev/.next/react-loadable-manifest.json +0 -1
- package/src/dev/.next/routes-manifest.json +0 -1
- package/src/dev/.next/server/app/[...slug]/page.js +0 -360
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app/page.js +0 -349
- package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app-paths-manifest.json +0 -3
- package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
- package/src/dev/.next/server/middleware-build-manifest.js +0 -33
- package/src/dev/.next/server/middleware-manifest.json +0 -32
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/src/dev/.next/server/middleware.js +0 -1113
- package/src/dev/.next/server/next-font-manifest.js +0 -1
- package/src/dev/.next/server/next-font-manifest.json +0 -1
- package/src/dev/.next/server/pages-manifest.json +0 -5
- package/src/dev/.next/server/server-reference-manifest.js +0 -1
- package/src/dev/.next/server/server-reference-manifest.json +0 -5
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
- package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
- package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
- package/src/dev/.next/server/webpack-runtime.js +0 -209
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
- package/src/dev/.next/static/chunks/app/layout.js +0 -171
- package/src/dev/.next/static/chunks/app/page.js +0 -28
- package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
- package/src/dev/.next/static/chunks/main-app.js +0 -1882
- package/src/dev/.next/static/chunks/polyfills.js +0 -1
- package/src/dev/.next/static/chunks/webpack.js +0 -1393
- package/src/dev/.next/static/css/app/layout.css +0 -559
- package/src/dev/.next/static/development/_buildManifest.js +0 -1
- package/src/dev/.next/static/development/_ssgManifest.js +0 -1
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
- package/src/dev/.next/trace +0 -21
- package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
- package/src/dev/.next/types/app/layout.ts +0 -84
- package/src/dev/.next/types/app/page.ts +0 -84
- package/src/dev/.next/types/cache-life.d.ts +0 -141
- package/src/dev/.next/types/package.json +0 -1
- package/src/dev/.next/types/routes.d.ts +0 -55
- package/src/validators/numbering.js +0 -134
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
const H1_RE = /^#\s+(.+)$/
|
|
2
|
+
const H2_RE = /^##\s+(.+)$/
|
|
3
|
+
const H3_RE = /^###\s+(.+)$/
|
|
4
|
+
|
|
5
|
+
// llms.txt might have a literal " (section)" marker to every
|
|
6
|
+
// grouping heading (e.g. `## getting-started (section)`). It's internal
|
|
7
|
+
// metadata, not part of the category name — strip it before it reaches the sidebar.
|
|
8
|
+
const SECTION_MARKER_RE = /\s*\(section\)\s*$/i
|
|
9
|
+
|
|
10
|
+
// Max items per section before we consider it "oversized" for usability purposes.
|
|
11
|
+
// Mirrors the cap in usableSections() in import.js.
|
|
12
|
+
const MAX_SECTION_ITEMS = 200
|
|
13
|
+
// Any line whose meaningful content is a markdown link. Accepts:
|
|
14
|
+
// - Standard list rows: `- [text](url)`, `* [text](url) — desc`
|
|
15
|
+
// - Bare link lines: `[text](url)`
|
|
16
|
+
// - Breadcrumb-prefixed rows used by Fern/Mintlify-style indices:
|
|
17
|
+
// `ElevenAgents [Agent WebSockets](url)`
|
|
18
|
+
// `API Reference > Agents > Branches [List branches](url)`
|
|
19
|
+
// - Trailing-parenthetical-breadcrumb rows (AssemblyAI/Fern variant) — the
|
|
20
|
+
// breadcrumb sits between the url and the description and is matched-and-discarded:
|
|
21
|
+
// `[text](url) (Breadcrumb > path): description`
|
|
22
|
+
// Captures: [prefix, text, url, description] (prefix and description may be empty).
|
|
23
|
+
const LINK_LINE_RE = /^\s*(?:[-*+]\s+)?([^\[\n]*?)\s*\[([^\]]+)\] ?\(([^)\s]+)\)(?:\s+\([^)]*\))?(?:\s*[:—–-]\s*(.+?))?\s*$/
|
|
24
|
+
const BLOCKQUOTE_RE = /^\s*>/
|
|
25
|
+
const FENCE_RE = /^\s*(?:```|~~~)/
|
|
26
|
+
|
|
27
|
+
// A llms.txt is considered usable as long as it has at least one link row.
|
|
28
|
+
// Beyond that we enforce a structural ratio (share of "spec-shaped" lines —
|
|
29
|
+
// H1/H2/blockquote/link row/blank — over total lines), but only once the
|
|
30
|
+
// file has enough links for the ratio to be statistically meaningful. A
|
|
31
|
+
// 3-link file with one prose sentence would compute a misleading ratio; we
|
|
32
|
+
// can't tell signal from noise at that size, so we trust it and let the
|
|
33
|
+
// BFS-merge dedupe handle any junk that slips through.
|
|
34
|
+
const RATIO_CHECK_MIN_LINKS = 10
|
|
35
|
+
const MIN_CONFORMING_RATIO = 0.7
|
|
36
|
+
|
|
37
|
+
export function analyzeLlmsTxt(body, llmsUrl, options = {}) {
|
|
38
|
+
const parsed = parseLlmsTxt(body, llmsUrl, options)
|
|
39
|
+
const lineStats = classifyLines(body)
|
|
40
|
+
const linkItems = parsed.sections.reduce((sum, s) => sum + s.items.length, 0)
|
|
41
|
+
const reason = getSkipReason(body, linkItems, lineStats)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
parsed,
|
|
45
|
+
usable: !reason,
|
|
46
|
+
reason,
|
|
47
|
+
stats: { ...lineStats, linkItems },
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseLlmsTxt(body, llmsUrl, options = {}) {
|
|
52
|
+
const h2Result = parseSections(body, llmsUrl, H2_RE, options)
|
|
53
|
+
|
|
54
|
+
// If every H2 section is oversized (or there are none), and the file has H3
|
|
55
|
+
// headings, re-parse treating H3 (###) as section boundaries.
|
|
56
|
+
// For strange LLMS.txt formatting
|
|
57
|
+
const allOversized = h2Result.sections.length === 0 ||
|
|
58
|
+
h2Result.sections.every((s) => s.items.length > MAX_SECTION_ITEMS)
|
|
59
|
+
if (allOversized && /^###\s/m.test(body)) {
|
|
60
|
+
const h3Result = parseSections(body, llmsUrl, H3_RE, options)
|
|
61
|
+
if (h3Result.sections.length > 1) {
|
|
62
|
+
return { ...h3Result, h3Fallback: true }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return h2Result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseSections(body, llmsUrl, sectionRe, options = {}) {
|
|
70
|
+
const lines = body.split(/\r?\n/)
|
|
71
|
+
let title = null
|
|
72
|
+
const sections = []
|
|
73
|
+
let current = null
|
|
74
|
+
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const h1 = line.match(H1_RE)
|
|
77
|
+
if (h1 && !title) {
|
|
78
|
+
title = h1[1].trim()
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sectionMatch = line.match(sectionRe)
|
|
83
|
+
if (sectionMatch) {
|
|
84
|
+
current = { title: sectionMatch[1].trim().replace(SECTION_MARKER_RE, '').trim(), items: [] }
|
|
85
|
+
sections.push(current)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const item = parseListLink(line, llmsUrl, options)
|
|
90
|
+
if (!item) continue
|
|
91
|
+
|
|
92
|
+
if (!current) {
|
|
93
|
+
current = { title: 'Resources', items: [] }
|
|
94
|
+
sections.push(current)
|
|
95
|
+
}
|
|
96
|
+
current.items.push(item)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { title, sections }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getSkipReason(body, linkItems, lineStats) {
|
|
103
|
+
if (/^---\r?\n/.test(body)) return 'starts with YAML frontmatter'
|
|
104
|
+
if (linkItems === 0) return 'no link items'
|
|
105
|
+
if (linkItems >= RATIO_CHECK_MIN_LINKS && lineStats.ratio < MIN_CONFORMING_RATIO) {
|
|
106
|
+
return `conforming-line ratio ${lineStats.ratio.toFixed(2)} below ${MIN_CONFORMING_RATIO} (${lineStats.conforming}/${lineStats.total} lines)`
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk the body line-by-line and bucket each line as conforming or not.
|
|
113
|
+
*
|
|
114
|
+
* conforming blank, H1, H2, blockquote, standard link-row
|
|
115
|
+
* non-conforming everything else, plus every line inside (and the delimiters
|
|
116
|
+
* of) a fenced code block
|
|
117
|
+
*/
|
|
118
|
+
function classifyLines(body) {
|
|
119
|
+
const lines = body.split(/\r?\n/)
|
|
120
|
+
let inFence = false
|
|
121
|
+
let conforming = 0
|
|
122
|
+
let nonConforming = 0
|
|
123
|
+
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (FENCE_RE.test(line)) {
|
|
126
|
+
nonConforming++
|
|
127
|
+
inFence = !inFence
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (inFence) {
|
|
131
|
+
nonConforming++
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (line.trim() === '') {
|
|
135
|
+
conforming++
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (H1_RE.test(line) || H2_RE.test(line) || LINK_LINE_RE.test(line) || BLOCKQUOTE_RE.test(line)) {
|
|
139
|
+
conforming++
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
nonConforming++
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const total = conforming + nonConforming
|
|
146
|
+
const ratio = total === 0 ? 0 : conforming / total
|
|
147
|
+
return { conforming, nonConforming, total, ratio }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseListLink(line, llmsUrl, options = {}) {
|
|
151
|
+
const match = line.match(LINK_LINE_RE)
|
|
152
|
+
if (!match) return null
|
|
153
|
+
|
|
154
|
+
const [, prefix, text, rawUrl, trailingDesc] = match
|
|
155
|
+
const url = normalizeUrl(rawUrl, llmsUrl, options)
|
|
156
|
+
if (!url) return null
|
|
157
|
+
|
|
158
|
+
// Prefer an explicit trailing description (`[text](url) — desc`); fall back
|
|
159
|
+
// to the breadcrumb prefix (`API Reference > Agents [text](url)`) when no
|
|
160
|
+
// explicit description is present. Either gives downstream extra context.
|
|
161
|
+
const description = trailingDesc?.trim() || prefix?.trim() || null
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
text: text.trim(),
|
|
165
|
+
url,
|
|
166
|
+
description: description || null,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeUrl(rawUrl, llmsUrl, options = {}) {
|
|
171
|
+
const trimmed = String(rawUrl || '').trim().replace(/[.,;]+$/, '')
|
|
172
|
+
if (!trimmed || /^#/.test(trimmed) || /^(mailto|javascript):/i.test(trimmed)) return null
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const url = llmsUrl ? new URL(resolveLlmsRelativeUrl(trimmed, llmsUrl, options), llmsUrl) : new URL(trimmed)
|
|
176
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
177
|
+
return url.toString()
|
|
178
|
+
} catch {
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveLlmsRelativeUrl(trimmed, llmsUrl, options) {
|
|
184
|
+
if (options.rootRelativeResolution !== 'llms-dir' || !isRootRelativeUrl(trimmed)) return trimmed
|
|
185
|
+
|
|
186
|
+
const nestedPrefix = getLlmsNestedPrefix(llmsUrl)
|
|
187
|
+
if (!nestedPrefix) return trimmed
|
|
188
|
+
|
|
189
|
+
const normallyResolved = new URL(trimmed, llmsUrl)
|
|
190
|
+
if (!shouldResolveRootRelativeFromLlmsDir(trimmed, normallyResolved.pathname, nestedPrefix)) return trimmed
|
|
191
|
+
|
|
192
|
+
return `.${trimmed}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function shouldResolveRootRelativeFromLlmsDir(rawUrl, pathname, nestedPrefix) {
|
|
196
|
+
if (isPathWithinPrefix(pathname, nestedPrefix)) return false
|
|
197
|
+
if (/\/llms\.txt$/i.test(pathname)) return false
|
|
198
|
+
|
|
199
|
+
const lastSegment = pathname.split('/').pop() || ''
|
|
200
|
+
const ext = lastSegment.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
|
|
201
|
+
if (ext && !['htm', 'html', 'md', 'mdx'].includes(ext)) return false
|
|
202
|
+
|
|
203
|
+
return isRootRelativeUrl(rawUrl)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getLlmsNestedPrefix(llmsUrl) {
|
|
207
|
+
if (!llmsUrl) return null
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const pathname = new URL(llmsUrl).pathname
|
|
211
|
+
const dir = pathname.replace(/llms\.txt$/i, '').replace(/\/+$/, '/')
|
|
212
|
+
return dir === '/' ? null : dir
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isRootRelativeUrl(rawUrl) {
|
|
219
|
+
return /^\/(?!\/)/.test(rawUrl)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isPathWithinPrefix(pathname, prefix) {
|
|
223
|
+
return pathname === prefix.replace(/\/$/, '') || pathname.startsWith(prefix)
|
|
224
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { parseLlmsTxt, analyzeLlmsTxt } from './llms.js'
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://docs.example.com/llms.txt'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// parseLlmsTxt — root-relative resolution
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
test('parseLlmsTxt: root-relative links resolve from the origin by default', () => {
|
|
12
|
+
const parsed = parseLlmsTxt(
|
|
13
|
+
`# Example\n\n## Guides\n- [Overview](/overview.md)\n- [Install](/docs/install.md)\n`,
|
|
14
|
+
'https://example.com/docs/llms.txt',
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
18
|
+
assert.deepEqual(urls, [
|
|
19
|
+
'https://example.com/overview.md',
|
|
20
|
+
'https://example.com/docs/install.md',
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('parseLlmsTxt: explicit llms-dir policy resolves nested root-relative page links from the llms.txt directory', () => {
|
|
25
|
+
const parsed = parseLlmsTxt(
|
|
26
|
+
`# Example\n\n## Guides\n- [Overview](/overview.md)\n- [API](/api/reference.md)\n- [Absolute](https://example.com/reference/openapi.md)\n`,
|
|
27
|
+
'https://example.com/docs/llms.txt',
|
|
28
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
32
|
+
assert.deepEqual(urls, [
|
|
33
|
+
'https://example.com/docs/overview.md',
|
|
34
|
+
'https://example.com/docs/api/reference.md',
|
|
35
|
+
'https://example.com/reference/openapi.md',
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('parseLlmsTxt: llms-dir policy preserves links already scoped to the llms.txt directory', () => {
|
|
40
|
+
const parsed = parseLlmsTxt(
|
|
41
|
+
`# Example\n\n## Guides\n- [Already scoped](/docs/reference.md)\n- [Deep already scoped](/docs/api/reference.md?version=1#auth)\n`,
|
|
42
|
+
'https://example.com/docs/llms.txt',
|
|
43
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
47
|
+
assert.deepEqual(urls, [
|
|
48
|
+
'https://example.com/docs/reference.md',
|
|
49
|
+
'https://example.com/docs/api/reference.md?version=1#auth',
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('parseLlmsTxt: llms-dir policy does not rewrite explicit nested llms.txt links', () => {
|
|
54
|
+
const parsed = parseLlmsTxt(
|
|
55
|
+
`# Example\n\n## Guides\n- [API llms](/api/llms.txt)\n- [Docs API llms](/docs/api/llms.txt)\n`,
|
|
56
|
+
'https://example.com/docs/llms.txt',
|
|
57
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
61
|
+
assert.deepEqual(urls, [
|
|
62
|
+
'https://example.com/api/llms.txt',
|
|
63
|
+
'https://example.com/docs/api/llms.txt',
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('parseLlmsTxt: llms-dir policy preserves root-scoped non-page files', () => {
|
|
68
|
+
const parsed = parseLlmsTxt(
|
|
69
|
+
`# Example\n\n## Guides\n- [Overview](/overview.md)\n- [OpenAPI](/reference/openapi.yaml)\n- [Image](/assets/logo.png)\n`,
|
|
70
|
+
'https://example.com/docs/llms.txt',
|
|
71
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
75
|
+
assert.deepEqual(urls, [
|
|
76
|
+
'https://example.com/docs/overview.md',
|
|
77
|
+
'https://example.com/reference/openapi.yaml',
|
|
78
|
+
'https://example.com/assets/logo.png',
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('parseLlmsTxt: llms-dir policy handles query and hash page links', () => {
|
|
83
|
+
const parsed = parseLlmsTxt(
|
|
84
|
+
`# Example\n\n## Guides\n- [Overview](/overview.md?tab=js#install)\n- [No extension](/get-started?tab=js#install)\n- [Hash only](#local)\n`,
|
|
85
|
+
'https://example.com/docs/llms.txt',
|
|
86
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
90
|
+
assert.deepEqual(urls, [
|
|
91
|
+
'https://example.com/docs/overview.md?tab=js#install',
|
|
92
|
+
'https://example.com/docs/get-started?tab=js#install',
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('parseLlmsTxt: malformed and unsupported links are ignored', () => {
|
|
97
|
+
const parsed = parseLlmsTxt(
|
|
98
|
+
`# Example\n\n## Guides\n- [Mail](mailto:test@example.com)\n- [JS](javascript:alert(1))\n- [OK](/ok.md)\n`,
|
|
99
|
+
'https://example.com/docs/llms.txt',
|
|
100
|
+
{ rootRelativeResolution: 'llms-dir' },
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const urls = parsed.sections[0].items.map((item) => item.url)
|
|
104
|
+
assert.deepEqual(urls, ['https://example.com/docs/ok.md'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// parseLlmsTxt — standard H2 sections
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
test('parseLlmsTxt: parses H1 title and H2 sections', () => {
|
|
112
|
+
const body = [
|
|
113
|
+
'# My Docs',
|
|
114
|
+
'',
|
|
115
|
+
'## Getting Started',
|
|
116
|
+
'- [Overview](https://docs.example.com/overview)',
|
|
117
|
+
'- [Quickstart](https://docs.example.com/quickstart)',
|
|
118
|
+
'',
|
|
119
|
+
'## Reference',
|
|
120
|
+
'- [API](https://docs.example.com/api)',
|
|
121
|
+
].join('\n')
|
|
122
|
+
|
|
123
|
+
const result = parseLlmsTxt(body, BASE)
|
|
124
|
+
assert.equal(result.title, 'My Docs')
|
|
125
|
+
assert.equal(result.sections.length, 2)
|
|
126
|
+
assert.equal(result.sections[0].title, 'Getting Started')
|
|
127
|
+
assert.equal(result.sections[0].items.length, 2)
|
|
128
|
+
assert.equal(result.sections[1].title, 'Reference')
|
|
129
|
+
assert.equal(result.h3Fallback, undefined)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('parseLlmsTxt: items with no H2 fall into implicit Resources section', () => {
|
|
133
|
+
const body = [
|
|
134
|
+
'# My Docs',
|
|
135
|
+
'- [Overview](https://docs.example.com/overview)',
|
|
136
|
+
].join('\n')
|
|
137
|
+
|
|
138
|
+
const result = parseLlmsTxt(body, BASE)
|
|
139
|
+
assert.equal(result.sections.length, 1)
|
|
140
|
+
assert.equal(result.sections[0].title, 'Resources')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// parseLlmsTxt — H3 fallback
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
// Generates a Couchbase-style body: one H2 bucket, N H3 sub-sections each
|
|
148
|
+
// with `itemsPerSection` pages. With itemsPerSection >= 68, the single H2
|
|
149
|
+
// section exceeds MAX_SECTION_ITEMS (200) and triggers the H3 fallback.
|
|
150
|
+
function makeCouchbaseBody(sections, itemsPerSection) {
|
|
151
|
+
const lines = ['# Couchbase', '', '> Official Couchbase docs.', '', '## Docs', '']
|
|
152
|
+
for (const title of sections) {
|
|
153
|
+
lines.push(`### ${title}`)
|
|
154
|
+
for (let i = 0; i < itemsPerSection; i++) {
|
|
155
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
|
156
|
+
lines.push(`- [Page ${i}](https://docs.couchbase.com/${slug}/page-${i})`)
|
|
157
|
+
}
|
|
158
|
+
lines.push('')
|
|
159
|
+
}
|
|
160
|
+
return lines.join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const CB_SECTIONS = ['.NET Analytics SDK (1.0)', '.NET Entity Framework (1.0)', '.NET SDK (3.9)']
|
|
164
|
+
|
|
165
|
+
test('parseLlmsTxt: falls back to H3 when single H2 section is oversized', () => {
|
|
166
|
+
const manyItems = Array.from({ length: 201 }, (_, i) =>
|
|
167
|
+
`- [Page ${i}](https://docs.example.com/page-${i})`
|
|
168
|
+
).join('\n')
|
|
169
|
+
|
|
170
|
+
const body = [
|
|
171
|
+
'# Big Site',
|
|
172
|
+
'',
|
|
173
|
+
'## Docs',
|
|
174
|
+
manyItems,
|
|
175
|
+
'',
|
|
176
|
+
'### Section A',
|
|
177
|
+
'- [A1](https://docs.example.com/a1)',
|
|
178
|
+
'- [A2](https://docs.example.com/a2)',
|
|
179
|
+
'',
|
|
180
|
+
'### Section B',
|
|
181
|
+
'- [B1](https://docs.example.com/b1)',
|
|
182
|
+
].join('\n')
|
|
183
|
+
|
|
184
|
+
const result = parseLlmsTxt(body, BASE)
|
|
185
|
+
assert.equal(result.h3Fallback, true)
|
|
186
|
+
assert.ok(result.sections.length > 1, `expected >1 section, got ${result.sections.length}`)
|
|
187
|
+
|
|
188
|
+
const titles = result.sections.map((s) => s.title)
|
|
189
|
+
assert.ok(titles.includes('Section A'))
|
|
190
|
+
assert.ok(titles.includes('Section B'))
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('parseLlmsTxt: Couchbase-style H3 structure produces one section per SDK', () => {
|
|
194
|
+
const body = makeCouchbaseBody(CB_SECTIONS, 70)
|
|
195
|
+
const result = parseLlmsTxt(body, 'https://docs.couchbase.com/llms.txt')
|
|
196
|
+
|
|
197
|
+
assert.equal(result.h3Fallback, true)
|
|
198
|
+
assert.equal(result.title, 'Couchbase')
|
|
199
|
+
assert.equal(result.sections.length, 3)
|
|
200
|
+
assert.equal(result.sections[0].title, '.NET Analytics SDK (1.0)')
|
|
201
|
+
assert.equal(result.sections[1].title, '.NET Entity Framework (1.0)')
|
|
202
|
+
assert.equal(result.sections[2].title, '.NET SDK (3.9)')
|
|
203
|
+
assert.equal(result.sections[0].items.length, 70)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('parseLlmsTxt: does NOT use H3 fallback when H2 sections are usable', () => {
|
|
207
|
+
const body = [
|
|
208
|
+
'# Site',
|
|
209
|
+
'',
|
|
210
|
+
'## Section A',
|
|
211
|
+
'- [A1](https://docs.example.com/a1)',
|
|
212
|
+
'- [A2](https://docs.example.com/a2)',
|
|
213
|
+
'',
|
|
214
|
+
'## Section B',
|
|
215
|
+
'- [B1](https://docs.example.com/b1)',
|
|
216
|
+
'',
|
|
217
|
+
'### Sub B',
|
|
218
|
+
'- [B2](https://docs.example.com/b2)',
|
|
219
|
+
].join('\n')
|
|
220
|
+
|
|
221
|
+
const result = parseLlmsTxt(body, BASE)
|
|
222
|
+
assert.equal(result.h3Fallback, undefined)
|
|
223
|
+
assert.equal(result.sections.length, 2)
|
|
224
|
+
assert.equal(result.sections[0].title, 'Section A')
|
|
225
|
+
assert.equal(result.sections[1].title, 'Section B')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('parseLlmsTxt: does NOT use H3 fallback when no H3 headings exist', () => {
|
|
229
|
+
const manyItems = Array.from({ length: 201 }, (_, i) =>
|
|
230
|
+
`- [Page ${i}](https://docs.example.com/page-${i})`
|
|
231
|
+
).join('\n')
|
|
232
|
+
|
|
233
|
+
const body = ['# Big Site', '', '## Docs', manyItems].join('\n')
|
|
234
|
+
|
|
235
|
+
const result = parseLlmsTxt(body, BASE)
|
|
236
|
+
assert.equal(result.h3Fallback, undefined)
|
|
237
|
+
assert.equal(result.sections.length, 1)
|
|
238
|
+
assert.equal(result.sections[0].title, 'Docs')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// analyzeLlmsTxt
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
test('analyzeLlmsTxt: marks usable when it has link items', () => {
|
|
246
|
+
const body = '## Section\n- [Page](https://docs.example.com/page)'
|
|
247
|
+
const result = analyzeLlmsTxt(body, BASE)
|
|
248
|
+
assert.equal(result.usable, true)
|
|
249
|
+
assert.equal(result.reason, null)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('analyzeLlmsTxt: marks not usable when no link items', () => {
|
|
253
|
+
const body = '# Just a title\n\nSome prose with no links.'
|
|
254
|
+
const result = analyzeLlmsTxt(body, BASE)
|
|
255
|
+
assert.equal(result.usable, false)
|
|
256
|
+
assert.ok(result.reason)
|
|
257
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL segment parsing utilities shared across import.js pipeline stages.
|
|
3
|
+
* Centralised here so the single-extension-strip bug only lives in one place.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Extensions that are implementation artifacts, not meaningful path segments.
|
|
7
|
+
const DOC_FILE_EXTENSIONS = ['md', 'mdx', 'html', 'htm', 'txt']
|
|
8
|
+
const DOC_EXT_RE = new RegExp(`(\\.(${DOC_FILE_EXTENSIONS.join('|')}))+$`, 'i')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Strip all chained doc-file extensions (.html.md, .html, .md, .mdx, .htm, .txt)
|
|
12
|
+
* from a single URL path segment. Handles the double-extension pattern used
|
|
13
|
+
* by some SSGs (e.g. `.html.md`).
|
|
14
|
+
*/
|
|
15
|
+
export function stripSegmentExtensions(s) {
|
|
16
|
+
return s.replace(DOC_EXT_RE, '')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a URL's pathname into segments suitable for trie nesting. Strips file
|
|
21
|
+
* extensions (including double-extensions like `.html.md`) and drops a trailing
|
|
22
|
+
* `index` segment so `/foo`, `/foo/`, `/foo/index.html`, `/foo/index.html.md`,
|
|
23
|
+
* and `/foo/index.md` all collapse to the same logical page.
|
|
24
|
+
* Returns null if the URL can't be parsed.
|
|
25
|
+
*/
|
|
26
|
+
export function urlTrieSegs(url) {
|
|
27
|
+
try {
|
|
28
|
+
const segs = new URL(url).pathname
|
|
29
|
+
.split('/')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((s) => stripSegmentExtensions(s))
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
|
|
34
|
+
return segs
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract URL path segments for slug planning. Strips file extensions
|
|
42
|
+
* (including double-extensions like `.html.md`) and leading numeric prefixes
|
|
43
|
+
* (e.g. `01-intro` → `intro`). Also drops a trailing `index` segment so
|
|
44
|
+
* `/foo/index.html.md` and `/foo/` both collapse to the same slug base.
|
|
45
|
+
*/
|
|
46
|
+
export function extractUrlPathSegments(url) {
|
|
47
|
+
if (!url) return []
|
|
48
|
+
try {
|
|
49
|
+
const segs = new URL(url).pathname
|
|
50
|
+
.split('/')
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((s) => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
|
|
55
|
+
return segs
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalise a URL to a canonical path key for deduplication. Strips trailing
|
|
63
|
+
* slash, strips all chained doc-file extensions from the path tail, drops a
|
|
64
|
+
* trailing `index`, and lowercases — so `/foo/bar.html.md`, `/foo/bar.html`,
|
|
65
|
+
* `/foo/bar/index.html`, and `/foo/bar` all resolve to the same key `/foo/bar`.
|
|
66
|
+
*/
|
|
67
|
+
export function normalizePath(url) {
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(url)
|
|
70
|
+
let p = u.pathname.replace(/\/$/, '').toLowerCase()
|
|
71
|
+
p = p.replace(DOC_EXT_RE, '')
|
|
72
|
+
p = p.replace(/\/index$/i, '')
|
|
73
|
+
return p || '/'
|
|
74
|
+
} catch {
|
|
75
|
+
return String(url).toLowerCase()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Dedupe key for llms.txt page rows only.
|
|
81
|
+
*
|
|
82
|
+
* Invariants:
|
|
83
|
+
* - includes origin so same-path docs on different hosts stay distinct
|
|
84
|
+
* - collapses doc URL spellings for the same page tail (.md/.mdx/.html/.htm, /index, trailing slash)
|
|
85
|
+
* - preserves query params because some docs routers encode page state there
|
|
86
|
+
* - ignores fragments because they identify anchors within a page, not separate import pages
|
|
87
|
+
*/
|
|
88
|
+
export function llmsPageDedupeKey(url) {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(url)
|
|
91
|
+
let p = u.pathname.replace(/\/$/, '').toLowerCase()
|
|
92
|
+
p = p.replace(DOC_EXT_RE, '')
|
|
93
|
+
p = p.replace(/\/index$/i, '')
|
|
94
|
+
|
|
95
|
+
const params = [...u.searchParams.entries()].sort(([ak, av], [bk, bv]) => (ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk)))
|
|
96
|
+
const query = params.length > 0 ? `?${params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')}` : ''
|
|
97
|
+
|
|
98
|
+
return `${u.origin.toLowerCase()}${p || '/'}${query}`
|
|
99
|
+
} catch {
|
|
100
|
+
return String(url).toLowerCase()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function compareCanonicalUrlPreference(a, b) {
|
|
105
|
+
const aRank = canonicalUrlPreferenceRank(a)
|
|
106
|
+
const bRank = canonicalUrlPreferenceRank(b)
|
|
107
|
+
if (aRank !== bRank) return aRank - bRank
|
|
108
|
+
return String(a || '').length - String(b || '').length
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function canonicalUrlPreferenceRank(url) {
|
|
112
|
+
try {
|
|
113
|
+
const pathname = new URL(url).pathname
|
|
114
|
+
const lower = pathname.toLowerCase()
|
|
115
|
+
if (/\.md$/.test(lower)) return 0
|
|
116
|
+
if (/\.txt$/.test(lower)) return 1
|
|
117
|
+
if (!/\/$/.test(pathname) && !/\/index\.html?$/.test(lower) && !/\.[^/]+$/.test(lower)) return 2
|
|
118
|
+
if (/\/$/.test(pathname)) return 3
|
|
119
|
+
if (/\/index\.html?$/.test(lower)) return 4
|
|
120
|
+
return 5
|
|
121
|
+
} catch {
|
|
122
|
+
return 5
|
|
123
|
+
}
|
|
124
|
+
}
|