@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
|
@@ -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,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the readmeio/marketplace repo: lists the available MDX components,
|
|
3
|
+
* fetches a single component's files (the .mdx source + its readme.md), and
|
|
4
|
+
* caches everything on disk so we're not hitting GitHub on every command.
|
|
5
|
+
*
|
|
6
|
+
* Components are read at https://github.com/readmeio/marketplace/tree/main/components
|
|
7
|
+
* and copied into the user's local `components/` folder. The marketplace
|
|
8
|
+
* `.mdx` files are the canonical source — they hold the JSX export plus an
|
|
9
|
+
* example invocation at the bottom.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const REPO = 'readmeio/marketplace';
|
|
17
|
+
const BRANCH = 'main';
|
|
18
|
+
const COMPONENTS_PATH = 'components';
|
|
19
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
20
|
+
|
|
21
|
+
const CACHE_DIR = path.join(os.homedir(), '.cache', 'readme-cli', 'marketplace');
|
|
22
|
+
|
|
23
|
+
function ensureCacheDir() {
|
|
24
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cachePath(name) {
|
|
28
|
+
return path.join(CACHE_DIR, name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readCache(name, maxAgeMs = CACHE_TTL_MS) {
|
|
32
|
+
const file = cachePath(name);
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.statSync(file);
|
|
35
|
+
if (Date.now() - stat.mtimeMs > maxAgeMs) return null;
|
|
36
|
+
return fs.readFileSync(file, 'utf-8');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeCache(name, contents) {
|
|
43
|
+
ensureCacheDir();
|
|
44
|
+
fs.writeFileSync(cachePath(name), contents, 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchText(url) {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: { 'User-Agent': '@readme/cli', Accept: 'application/vnd.github.v3+json' },
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
return res.text();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the list of marketplace components as an array of:
|
|
59
|
+
* { name, mdxUrl, readmeUrl, htmlUrl }
|
|
60
|
+
* Hidden entries (e.g. `.ExampleComponent`) are filtered out.
|
|
61
|
+
*/
|
|
62
|
+
export async function listMarketplaceComponents({ force = false } = {}) {
|
|
63
|
+
const cacheKey = 'index.json';
|
|
64
|
+
if (!force) {
|
|
65
|
+
const cached = readCache(cacheKey);
|
|
66
|
+
if (cached) {
|
|
67
|
+
try { return JSON.parse(cached); } catch { /* fall through */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const url = `https://api.github.com/repos/${REPO}/contents/${COMPONENTS_PATH}?ref=${BRANCH}`;
|
|
72
|
+
const body = await fetchText(url);
|
|
73
|
+
const entries = JSON.parse(body);
|
|
74
|
+
|
|
75
|
+
const components = entries
|
|
76
|
+
.filter((e) => e.type === 'dir' && !e.name.startsWith('.'))
|
|
77
|
+
.map((e) => ({
|
|
78
|
+
name: e.name,
|
|
79
|
+
htmlUrl: `https://github.com/${REPO}/tree/${BRANCH}/${COMPONENTS_PATH}/${e.name}`,
|
|
80
|
+
mdxUrl: `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${COMPONENTS_PATH}/${e.name}/${e.name}.mdx`,
|
|
81
|
+
readmeUrl: `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${COMPONENTS_PATH}/${e.name}/readme.md`,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
writeCache(cacheKey, JSON.stringify(components, null, 2));
|
|
85
|
+
return components;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch the .mdx + readme.md for a single component. Returns:
|
|
90
|
+
* { name, mdx, readme, htmlUrl }
|
|
91
|
+
* `readme` may be null if the component has no readme.md.
|
|
92
|
+
*/
|
|
93
|
+
export async function fetchMarketplaceComponent(name, { force = false } = {}) {
|
|
94
|
+
const components = await listMarketplaceComponents({ force });
|
|
95
|
+
const match = components.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new Error(`Component "${name}" not found in marketplace.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mdxCacheKey = `${match.name}.mdx`;
|
|
101
|
+
const readmeCacheKey = `${match.name}.readme.md`;
|
|
102
|
+
|
|
103
|
+
let mdx = !force && readCache(mdxCacheKey);
|
|
104
|
+
if (!mdx) {
|
|
105
|
+
mdx = await fetchText(match.mdxUrl);
|
|
106
|
+
writeCache(mdxCacheKey, mdx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let readme = !force && readCache(readmeCacheKey);
|
|
110
|
+
if (!readme) {
|
|
111
|
+
try {
|
|
112
|
+
readme = await fetchText(match.readmeUrl);
|
|
113
|
+
writeCache(readmeCacheKey, readme);
|
|
114
|
+
} catch {
|
|
115
|
+
readme = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { name: match.name, mdx, readme, htmlUrl: match.htmlUrl };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pull the one-line summary out of a component's readme.md. We look for the
|
|
124
|
+
* first non-empty paragraph after the `## Overview` heading, falling back to
|
|
125
|
+
* the first non-heading line. Returns "" if nothing usable is found.
|
|
126
|
+
*/
|
|
127
|
+
export function summarizeReadme(readme) {
|
|
128
|
+
if (!readme) return '';
|
|
129
|
+
const lines = readme.split('\n');
|
|
130
|
+
let inOverview = false;
|
|
131
|
+
for (let i = 0; i < lines.length; i++) {
|
|
132
|
+
const line = lines[i].trim();
|
|
133
|
+
if (/^##\s+overview/i.test(line)) { inOverview = true; continue; }
|
|
134
|
+
if (inOverview && line && !line.startsWith('#') && !line.startsWith('![')) {
|
|
135
|
+
return line.replace(/[*_`]/g, '').slice(0, 140);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Fallback: first non-heading, non-image line.
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const t = line.trim();
|
|
141
|
+
if (t && !t.startsWith('#') && !t.startsWith('![') && !t.startsWith('<')) {
|
|
142
|
+
return t.replace(/[*_`]/g, '').slice(0, 140);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pull the first example invocation block out of a component's mdx. Components
|
|
150
|
+
* conventionally end with a bare `<ComponentName ... />` line below the React
|
|
151
|
+
* export — return that. Returns the full mdx as a fallback.
|
|
152
|
+
*/
|
|
153
|
+
export function extractExample(mdx, name) {
|
|
154
|
+
const re = new RegExp(`^<${name}[\\s\\S]*?(?:/>|</${name}>)\\s*$`, 'm');
|
|
155
|
+
const match = mdx.match(re);
|
|
156
|
+
return match ? match[0] : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Returns the absolute path to the local `components/` folder for a repo
|
|
161
|
+
* (relative to gitRoot). Does not create it.
|
|
162
|
+
*/
|
|
163
|
+
export function localComponentsDir(gitRoot) {
|
|
164
|
+
return path.join(gitRoot, 'components');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read a previously-cached readme for a component without hitting the network.
|
|
169
|
+
* Returns null if there's nothing cached. Used so we can show short
|
|
170
|
+
* descriptions in prompts/lists without slowing down the command on a cold
|
|
171
|
+
* cache.
|
|
172
|
+
*/
|
|
173
|
+
export function readCachedReadme(name) {
|
|
174
|
+
return readCache(`${name}.readme.md`, Infinity);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Install a marketplace component into the user's local components/ folder.
|
|
179
|
+
* Used by both the `components add` subcommand and the auto-install path in
|
|
180
|
+
* `pretty`. Returns the destination path on success.
|
|
181
|
+
*/
|
|
182
|
+
export async function installComponent(gitRoot, name, { force = false } = {}) {
|
|
183
|
+
const comp = await fetchMarketplaceComponent(name, { force });
|
|
184
|
+
const dest = localComponentsDir(gitRoot);
|
|
185
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
186
|
+
const file = path.join(dest, `${comp.name}.mdx`);
|
|
187
|
+
fs.writeFileSync(file, comp.mdx, 'utf-8');
|
|
188
|
+
return { name: comp.name, file, mdx: comp.mdx };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List components that have been copied into the user's local components/
|
|
193
|
+
* folder. Returns an array of { name, file, source } where `source` is the
|
|
194
|
+
* raw mdx contents.
|
|
195
|
+
*/
|
|
196
|
+
export function listLocalComponents(gitRoot) {
|
|
197
|
+
const dir = localComponentsDir(gitRoot);
|
|
198
|
+
if (!fs.existsSync(dir)) return [];
|
|
199
|
+
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
202
|
+
if (!entry.endsWith('.mdx')) continue;
|
|
203
|
+
const file = path.join(dir, entry);
|
|
204
|
+
const source = fs.readFileSync(file, 'utf-8');
|
|
205
|
+
out.push({ name: entry.replace(/\.mdx$/, ''), file, source });
|
|
206
|
+
}
|
|
207
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
208
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createLogUpdate } from 'log-update';
|
|
2
|
+
import cliSpinners from 'cli-spinners';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Multi-row spinner. Renders `rows` labeled lines, repainted in unison at the
|
|
7
|
+
* spinner's tick interval. Cleared rows render as blank lines (the live region
|
|
8
|
+
* keeps a fixed height). Falls back to no-op render + line-buffered `log()`
|
|
9
|
+
* in non-TTY environments.
|
|
10
|
+
*/
|
|
11
|
+
export class MultiSpinner {
|
|
12
|
+
constructor({ rows, color = 'blue', stream = process.stderr } = {}) {
|
|
13
|
+
this.rows = Math.max(0, rows);
|
|
14
|
+
this.color = color;
|
|
15
|
+
this.stream = stream;
|
|
16
|
+
this.isTTY = !!stream.isTTY;
|
|
17
|
+
this.lines = new Array(this.rows).fill('');
|
|
18
|
+
this.frame = 0;
|
|
19
|
+
this.spinner = cliSpinners.dots;
|
|
20
|
+
this.timer = null;
|
|
21
|
+
this.update = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() {
|
|
25
|
+
if (!this.isTTY) return;
|
|
26
|
+
this.update = createLogUpdate(this.stream);
|
|
27
|
+
this._render();
|
|
28
|
+
this.timer = setInterval(() => this._render(), this.spinner.interval);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setLine(rowIdx, text) {
|
|
32
|
+
if (!this.isTTY) return;
|
|
33
|
+
if (rowIdx < 0 || rowIdx >= this.rows) return;
|
|
34
|
+
this.lines[rowIdx] = text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clearLine(rowIdx) {
|
|
38
|
+
this.setLine(rowIdx, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
log(text) {
|
|
42
|
+
if (this.isTTY && this.update) {
|
|
43
|
+
this.update.persist(text);
|
|
44
|
+
} else {
|
|
45
|
+
this.stream.write(text + '\n');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
if (this.timer) {
|
|
51
|
+
clearInterval(this.timer);
|
|
52
|
+
this.timer = null;
|
|
53
|
+
}
|
|
54
|
+
if (this.update) {
|
|
55
|
+
this.update.clear();
|
|
56
|
+
this.update.done();
|
|
57
|
+
this.update = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_render() {
|
|
62
|
+
if (!this.update) return;
|
|
63
|
+
const glyph = this.spinner.frames[this.frame % this.spinner.frames.length];
|
|
64
|
+
const colorize = chalk[this.color] ?? ((s) => s);
|
|
65
|
+
const out = this.lines
|
|
66
|
+
.map((text) => (text ? `${colorize(glyph)} ${text}` : ''))
|
|
67
|
+
.join('\n');
|
|
68
|
+
this.update(out);
|
|
69
|
+
this.frame++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `worker` over `items` with at most `concurrency` calls in flight.
|
|
3
|
+
* Each worker pulls the next item from a shared cursor; finishes when none left.
|
|
4
|
+
* `worker(item, index, workerIdx)` is responsible for catching its own errors —
|
|
5
|
+
* an uncaught rejection will propagate up and abort other in-flight workers.
|
|
6
|
+
*/
|
|
7
|
+
export async function runPool(items, concurrency, worker) {
|
|
8
|
+
let cursor = 0;
|
|
9
|
+
const next = () => (cursor < items.length ? cursor++ : -1);
|
|
10
|
+
const lanes = Math.max(1, Math.min(concurrency, items.length));
|
|
11
|
+
const workers = Array.from({ length: lanes }, (_, workerIdx) => (async () => {
|
|
12
|
+
for (let i = next(); i !== -1; i = next()) {
|
|
13
|
+
await worker(items[i], i, workerIdx);
|
|
14
|
+
}
|
|
15
|
+
})());
|
|
16
|
+
await Promise.all(workers);
|
|
17
|
+
}
|
|
@@ -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
|
+
}
|