@readme/cli 0.0.31 → 0.2.0

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.
Files changed (54) hide show
  1. package/README.md +10 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +34 -24
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2263 -2066
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +89 -0
  12. package/src/commands/oas-sync.js +96 -93
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +177 -164
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/eyes.js +143 -51
  25. package/src/utils/git.js +3 -3
  26. package/src/utils/lint.js +39 -38
  27. package/src/utils/llms.js +94 -91
  28. package/src/utils/markdown.js +256 -0
  29. package/src/utils/marketplace.js +12 -5
  30. package/src/utils/multi-spinner.js +5 -7
  31. package/src/utils/promise-pool.js +7 -5
  32. package/src/utils/reporter.js +88 -91
  33. package/src/utils/setup-templates.js +12 -6
  34. package/src/utils/styles.js +2 -1
  35. package/src/utils/tamagotchi.js +257 -91
  36. package/src/utils/tips.js +18 -9
  37. package/src/utils/url-segs.js +46 -41
  38. package/src/validators/components.js +55 -63
  39. package/src/validators/content.js +5 -10
  40. package/src/validators/duplicates.js +6 -2
  41. package/src/validators/frontmatter.js +19 -21
  42. package/src/validators/links.js +4 -8
  43. package/src/validators/oas-reference.js +7 -14
  44. package/src/validators/oas-schema.js +2 -0
  45. package/src/validators/ordering.js +5 -5
  46. package/src/validators/recipes.js +4 -12
  47. package/src/commands/import-oas.test.js +0 -277
  48. package/src/commands/import.classify.test.js +0 -172
  49. package/src/commands/import.landing.test.js +0 -217
  50. package/src/commands/import.slug-planner.test.js +0 -86
  51. package/src/commands/import.staging.test.js +0 -158
  52. package/src/commands/import.test.js +0 -382
  53. package/src/utils/llms.test.js +0 -302
  54. package/src/utils/url-segs.test.js +0 -239
package/src/utils/lint.js CHANGED
@@ -1,15 +1,16 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import * as componentsValidator from '../validators/components.js'
4
- import * as contentValidator from '../validators/content.js'
5
- import * as duplicatesValidator from '../validators/duplicates.js'
6
- import * as frontmatterValidator from '../validators/frontmatter.js'
7
- import * as linksValidator from '../validators/links.js'
8
- import * as nestingValidator from '../validators/nesting.js'
9
- import * as oasReferenceValidator from '../validators/oas-reference.js'
10
- import * as oasSchemaValidator from '../validators/oas-schema.js'
11
- import * as orderingValidator from '../validators/ordering.js'
12
- import * as recipesValidator from '../validators/recipes.js'
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import * as componentsValidator from '../validators/components.js';
5
+ import * as contentValidator from '../validators/content.js';
6
+ import * as duplicatesValidator from '../validators/duplicates.js';
7
+ import * as frontmatterValidator from '../validators/frontmatter.js';
8
+ import * as linksValidator from '../validators/links.js';
9
+ import * as nestingValidator from '../validators/nesting.js';
10
+ import * as oasReferenceValidator from '../validators/oas-reference.js';
11
+ import * as oasSchemaValidator from '../validators/oas-schema.js';
12
+ import * as orderingValidator from '../validators/ordering.js';
13
+ import * as recipesValidator from '../validators/recipes.js';
13
14
 
14
15
  const TARGET_PATTERNS = [
15
16
  { dir: 'custom_blocks', ext: ['.mdx', '.md'] },
@@ -17,28 +18,28 @@ const TARGET_PATTERNS = [
17
18
  { dir: 'reference', ext: '.md' },
18
19
  { dir: 'custom_pages', ext: '.md' },
19
20
  { dir: 'recipes', ext: '.md' },
20
- ]
21
+ ];
21
22
 
22
23
  /**
23
24
  * Collect all target files from the repo root.
24
25
  */
25
26
  export function collectFiles(gitRoot) {
26
- const files = []
27
+ const files = [];
27
28
 
28
29
  for (const { dir, ext } of TARGET_PATTERNS) {
29
- const dirPath = path.join(gitRoot, dir)
30
- if (!fs.existsSync(dirPath)) continue
30
+ const dirPath = path.join(gitRoot, dir);
31
+ if (!fs.existsSync(dirPath)) continue;
31
32
 
32
- const exts = Array.isArray(ext) ? ext : [ext]
33
- const entries = fs.readdirSync(dirPath, { recursive: true })
33
+ const exts = Array.isArray(ext) ? ext : [ext];
34
+ const entries = fs.readdirSync(dirPath, { recursive: true });
34
35
  for (const entry of entries) {
35
- if (exts.some((e) => entry.endsWith(e))) {
36
- files.push(path.join(dir, entry))
36
+ if (exts.some(e => entry.endsWith(e))) {
37
+ files.push(path.join(dir, entry));
37
38
  }
38
39
  }
39
40
  }
40
41
 
41
- return files.sort()
42
+ return files.sort();
42
43
  }
43
44
 
44
45
  // Every validator in src/validators/, imported statically (rather than
@@ -57,14 +58,14 @@ const ALL_VALIDATORS = [
57
58
  oasSchemaValidator,
58
59
  orderingValidator,
59
60
  recipesValidator,
60
- ]
61
+ ];
61
62
 
62
63
  /**
63
64
  * Validators can export `validate()` for per-file checks and/or
64
65
  * `validateAll()` for cross-file checks (like ordering).
65
66
  */
66
67
  function loadValidators() {
67
- return ALL_VALIDATORS.filter((mod) => mod.name && (mod.validate || mod.validateAll))
68
+ return ALL_VALIDATORS.filter(mod => mod.name && (mod.validate || mod.validateAll));
68
69
  }
69
70
 
70
71
  /**
@@ -73,42 +74,42 @@ function loadValidators() {
73
74
  * Calls `onFile(relativePath)` before processing each file (for progress reporting).
74
75
  */
75
76
  export async function runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive } = {}) {
76
- const validators = await loadValidators()
77
- const results = []
77
+ const validators = await loadValidators();
78
+ const results = [];
78
79
 
79
80
  // Per-file validators.
80
81
  for (const relativePath of files) {
81
- if (onFile) onFile(relativePath)
82
+ if (onFile) onFile(relativePath);
82
83
 
83
- const filePath = path.join(gitRoot, relativePath)
84
- const content = fs.readFileSync(filePath, 'utf-8')
84
+ const filePath = path.join(gitRoot, relativePath);
85
+ const content = fs.readFileSync(filePath, 'utf-8');
85
86
 
86
87
  for (const validator of validators) {
87
- if (!validator.validate) continue
88
- const result = validator.validate({ filePath, content, relativePath, fix })
88
+ if (!validator.validate) continue;
89
+ const result = validator.validate({ filePath, content, relativePath, fix });
89
90
  if (result) {
90
91
  if (Array.isArray(result)) {
91
- results.push(...result)
92
+ results.push(...result);
92
93
  } else {
93
- results.push(result)
94
+ results.push(result);
94
95
  }
95
96
  }
96
97
  }
97
98
  }
98
99
 
99
100
  // Cross-file validators — stop the spinner first so interactive prompts work.
100
- if (onBeforeCrossFile) onBeforeCrossFile()
101
+ if (onBeforeCrossFile) onBeforeCrossFile();
101
102
  for (const validator of validators) {
102
- if (!validator.validateAll) continue
103
- const result = await validator.validateAll(files, gitRoot, { fix, nonInteractive })
103
+ if (!validator.validateAll) continue;
104
+ const result = await validator.validateAll(files, gitRoot, { fix, nonInteractive });
104
105
  if (result) {
105
106
  if (Array.isArray(result)) {
106
- results.push(...result)
107
+ results.push(...result);
107
108
  } else {
108
- results.push(result)
109
+ results.push(result);
109
110
  }
110
111
  }
111
112
  }
112
113
 
113
- return results
114
+ return results;
114
115
  }
package/src/utils/llms.js CHANGED
@@ -1,15 +1,15 @@
1
- const H1_RE = /^#\s+(.+)$/
2
- const H2_RE = /^##\s+(.+)$/
3
- const H3_RE = /^###\s+(.+)$/
1
+ const H1_RE = /^#\s+(.+)$/;
2
+ const H2_RE = /^##\s+(.+)$/;
3
+ const H3_RE = /^###\s+(.+)$/;
4
4
 
5
5
  // llms.txt might have a literal " (section)" marker to every
6
6
  // grouping heading (e.g. `## getting-started (section)`). It's internal
7
7
  // metadata, not part of the category name — strip it before it reaches the sidebar.
8
- const SECTION_MARKER_RE = /\s*\(section\)\s*$/i
8
+ const SECTION_MARKER_RE = /\s*\(section\)\s*$/i;
9
9
 
10
10
  // Max items per section before we consider it "oversized" for usability purposes.
11
11
  // Mirrors the cap in usableSections() in import.js.
12
- const MAX_SECTION_ITEMS = 200
12
+ const MAX_SECTION_ITEMS = 200;
13
13
  // Any line whose meaningful content is a markdown link. Accepts:
14
14
  // - Standard list rows: `- [text](url)`, `* [text](url) — desc`
15
15
  // - Bare link lines: `[text](url)`
@@ -20,9 +20,10 @@ const MAX_SECTION_ITEMS = 200
20
20
  // breadcrumb sits between the url and the description and is matched-and-discarded:
21
21
  // `[text](url) (Breadcrumb > path): description`
22
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*(?:```|~~~)/
23
+ const LINK_LINE_RE =
24
+ /^\s*(?:[-*+]\s+)?([^[\n]*?)\s*\[([^\]]+)\] ?\(([^)\s]+)\)(?:\s+\([^)]*\))?(?:\s*[:—–-]\s*(.+?))?\s*$/;
25
+ const BLOCKQUOTE_RE = /^\s*>/;
26
+ const FENCE_RE = /^\s*(?:```|~~~)/;
26
27
 
27
28
  // A llms.txt is considered usable as long as it has at least one link row.
28
29
  // Beyond that we enforce a structural ratio (share of "spec-shaped" lines —
@@ -31,81 +32,81 @@ const FENCE_RE = /^\s*(?:```|~~~)/
31
32
  // 3-link file with one prose sentence would compute a misleading ratio; we
32
33
  // can't tell signal from noise at that size, so we trust it and let the
33
34
  // BFS-merge dedupe handle any junk that slips through.
34
- const RATIO_CHECK_MIN_LINKS = 10
35
- const MIN_CONFORMING_RATIO = 0.7
35
+ const RATIO_CHECK_MIN_LINKS = 10;
36
+ const MIN_CONFORMING_RATIO = 0.7;
36
37
 
37
38
  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)
39
+ const parsed = parseLlmsTxt(body, llmsUrl, options);
40
+ const lineStats = classifyLines(body);
41
+ const linkItems = parsed.sections.reduce((sum, s) => sum + s.items.length, 0);
42
+ const reason = getSkipReason(body, linkItems, lineStats);
42
43
 
43
44
  return {
44
45
  parsed,
45
46
  usable: !reason,
46
47
  reason,
47
48
  stats: { ...lineStats, linkItems },
48
- }
49
+ };
49
50
  }
50
51
 
51
52
  export function parseLlmsTxt(body, llmsUrl, options = {}) {
52
- const h2Result = parseSections(body, llmsUrl, H2_RE, options)
53
+ const h2Result = parseSections(body, llmsUrl, H2_RE, options);
53
54
 
54
55
  // If every H2 section is oversized (or there are none), and the file has H3
55
56
  // headings, re-parse treating H3 (###) as section boundaries.
56
57
  // For strange LLMS.txt formatting
57
- const allOversized = h2Result.sections.length === 0 ||
58
- h2Result.sections.every((s) => s.items.length > MAX_SECTION_ITEMS)
58
+ const allOversized =
59
+ h2Result.sections.length === 0 || h2Result.sections.every(s => s.items.length > MAX_SECTION_ITEMS);
59
60
  if (allOversized && /^###\s/m.test(body)) {
60
- const h3Result = parseSections(body, llmsUrl, H3_RE, options)
61
+ const h3Result = parseSections(body, llmsUrl, H3_RE, options);
61
62
  if (h3Result.sections.length > 1) {
62
- return { ...h3Result, h3Fallback: true }
63
+ return { ...h3Result, h3Fallback: true };
63
64
  }
64
65
  }
65
66
 
66
- return h2Result
67
+ return h2Result;
67
68
  }
68
69
 
69
70
  function parseSections(body, llmsUrl, sectionRe, options = {}) {
70
- const lines = body.split(/\r?\n/)
71
- let title = null
72
- const sections = []
73
- let current = null
71
+ const lines = body.split(/\r?\n/);
72
+ let title = null;
73
+ const sections = [];
74
+ let current = null;
74
75
 
75
76
  for (const line of lines) {
76
- const h1 = line.match(H1_RE)
77
+ const h1 = line.match(H1_RE);
77
78
  if (h1 && !title) {
78
- title = h1[1].trim()
79
- continue
79
+ title = h1[1].trim();
80
+ continue;
80
81
  }
81
82
 
82
- const sectionMatch = line.match(sectionRe)
83
+ const sectionMatch = line.match(sectionRe);
83
84
  if (sectionMatch) {
84
- current = { title: sectionMatch[1].trim().replace(SECTION_MARKER_RE, '').trim(), items: [] }
85
- sections.push(current)
86
- continue
85
+ current = { title: sectionMatch[1].trim().replace(SECTION_MARKER_RE, '').trim(), items: [] };
86
+ sections.push(current);
87
+ continue;
87
88
  }
88
89
 
89
- const item = parseListLink(line, llmsUrl, options)
90
- if (!item) continue
90
+ const item = parseListLink(line, llmsUrl, options);
91
+ if (!item) continue;
91
92
 
92
93
  if (!current) {
93
- current = { title: 'Resources', items: [] }
94
- sections.push(current)
94
+ current = { title: 'Resources', items: [] };
95
+ sections.push(current);
95
96
  }
96
- current.items.push(item)
97
+ current.items.push(item);
97
98
  }
98
99
 
99
- return { title, sections }
100
+ return { title, sections };
100
101
  }
101
102
 
102
103
  function getSkipReason(body, linkItems, lineStats) {
103
- if (/^---\r?\n/.test(body)) return 'starts with YAML frontmatter'
104
- if (linkItems === 0) return 'no link items'
104
+ if (/^---\r?\n/.test(body)) return 'starts with YAML frontmatter';
105
+ if (linkItems === 0) return 'no link items';
105
106
  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
+ return `conforming-line ratio ${lineStats.ratio.toFixed(2)} below ${MIN_CONFORMING_RATIO} (${lineStats.conforming}/${lineStats.total} lines)`;
107
108
  }
108
- return null
109
+ return null;
109
110
  }
110
111
 
111
112
  /**
@@ -116,109 +117,111 @@ function getSkipReason(body, linkItems, lineStats) {
116
117
  * of) a fenced code block
117
118
  */
118
119
  function classifyLines(body) {
119
- const lines = body.split(/\r?\n/)
120
- let inFence = false
121
- let conforming = 0
122
- let nonConforming = 0
120
+ const lines = body.split(/\r?\n/);
121
+ let inFence = false;
122
+ let conforming = 0;
123
+ let nonConforming = 0;
123
124
 
124
125
  for (const line of lines) {
125
126
  if (FENCE_RE.test(line)) {
126
- nonConforming++
127
- inFence = !inFence
128
- continue
127
+ nonConforming++;
128
+ inFence = !inFence;
129
+ continue;
129
130
  }
130
131
  if (inFence) {
131
- nonConforming++
132
- continue
132
+ nonConforming++;
133
+ continue;
133
134
  }
134
135
  if (line.trim() === '') {
135
- conforming++
136
- continue
136
+ conforming++;
137
+ continue;
137
138
  }
138
139
  if (H1_RE.test(line) || H2_RE.test(line) || LINK_LINE_RE.test(line) || BLOCKQUOTE_RE.test(line)) {
139
- conforming++
140
- continue
140
+ conforming++;
141
+ continue;
141
142
  }
142
- nonConforming++
143
+ nonConforming++;
143
144
  }
144
145
 
145
- const total = conforming + nonConforming
146
- const ratio = total === 0 ? 0 : conforming / total
147
- return { conforming, nonConforming, total, ratio }
146
+ const total = conforming + nonConforming;
147
+ const ratio = total === 0 ? 0 : conforming / total;
148
+ return { conforming, nonConforming, total, ratio };
148
149
  }
149
150
 
150
151
  function parseListLink(line, llmsUrl, options = {}) {
151
- const match = line.match(LINK_LINE_RE)
152
- if (!match) return null
152
+ const match = line.match(LINK_LINE_RE);
153
+ if (!match) return null;
153
154
 
154
- const [, prefix, text, rawUrl, trailingDesc] = match
155
- const url = normalizeUrl(rawUrl, llmsUrl, options)
156
- if (!url) return null
155
+ const [, prefix, text, rawUrl, trailingDesc] = match;
156
+ const url = normalizeUrl(rawUrl, llmsUrl, options);
157
+ if (!url) return null;
157
158
 
158
159
  // Prefer an explicit trailing description (`[text](url) — desc`); fall back
159
160
  // to the breadcrumb prefix (`API Reference > Agents [text](url)`) when no
160
161
  // explicit description is present. Either gives downstream extra context.
161
- const description = trailingDesc?.trim() || prefix?.trim() || null
162
+ const description = trailingDesc?.trim() || prefix?.trim() || null;
162
163
 
163
164
  return {
164
165
  text: text.trim(),
165
166
  url,
166
167
  description: description || null,
167
- }
168
+ };
168
169
  }
169
170
 
170
171
  function normalizeUrl(rawUrl, llmsUrl, options = {}) {
171
- const trimmed = String(rawUrl || '').trim().replace(/[.,;]+$/, '')
172
- if (!trimmed || /^#/.test(trimmed) || /^(mailto|javascript):/i.test(trimmed)) return null
172
+ const trimmed = String(rawUrl || '')
173
+ .trim()
174
+ .replace(/[.,;]+$/, '');
175
+ if (!trimmed || trimmed.startsWith('#') || /^(mailto|javascript):/i.test(trimmed)) return null;
173
176
 
174
177
  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
+ const url = llmsUrl ? new URL(resolveLlmsRelativeUrl(trimmed, llmsUrl, options), llmsUrl) : new URL(trimmed);
179
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
180
+ return url.toString();
178
181
  } catch {
179
- return null
182
+ return null;
180
183
  }
181
184
  }
182
185
 
183
186
  function resolveLlmsRelativeUrl(trimmed, llmsUrl, options) {
184
- if (options.rootRelativeResolution !== 'llms-dir' || !isRootRelativeUrl(trimmed)) return trimmed
187
+ if (options.rootRelativeResolution !== 'llms-dir' || !isRootRelativeUrl(trimmed)) return trimmed;
185
188
 
186
- const nestedPrefix = getLlmsNestedPrefix(llmsUrl)
187
- if (!nestedPrefix) return trimmed
189
+ const nestedPrefix = getLlmsNestedPrefix(llmsUrl);
190
+ if (!nestedPrefix) return trimmed;
188
191
 
189
- const normallyResolved = new URL(trimmed, llmsUrl)
190
- if (!shouldResolveRootRelativeFromLlmsDir(trimmed, normallyResolved.pathname, nestedPrefix)) return trimmed
192
+ const normallyResolved = new URL(trimmed, llmsUrl);
193
+ if (!shouldResolveRootRelativeFromLlmsDir(trimmed, normallyResolved.pathname, nestedPrefix)) return trimmed;
191
194
 
192
- return `.${trimmed}`
195
+ return `.${trimmed}`;
193
196
  }
194
197
 
195
198
  function shouldResolveRootRelativeFromLlmsDir(rawUrl, pathname, nestedPrefix) {
196
- if (isPathWithinPrefix(pathname, nestedPrefix)) return false
197
- if (/\/llms\.txt$/i.test(pathname)) return false
199
+ if (isPathWithinPrefix(pathname, nestedPrefix)) return false;
200
+ if (/\/llms\.txt$/i.test(pathname)) return false;
198
201
 
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
+ const lastSegment = pathname.split('/').pop() || '';
203
+ const ext = lastSegment.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
204
+ if (ext && !['htm', 'html', 'md', 'mdx'].includes(ext)) return false;
202
205
 
203
- return isRootRelativeUrl(rawUrl)
206
+ return isRootRelativeUrl(rawUrl);
204
207
  }
205
208
 
206
209
  function getLlmsNestedPrefix(llmsUrl) {
207
- if (!llmsUrl) return null
210
+ if (!llmsUrl) return null;
208
211
 
209
212
  try {
210
- const pathname = new URL(llmsUrl).pathname
211
- const dir = pathname.replace(/llms\.txt$/i, '').replace(/\/+$/, '/')
212
- return dir === '/' ? null : dir
213
+ const pathname = new URL(llmsUrl).pathname;
214
+ const dir = pathname.replace(/llms\.txt$/i, '').replace(/\/+$/, '/');
215
+ return dir === '/' ? null : dir;
213
216
  } catch {
214
- return null
217
+ return null;
215
218
  }
216
219
  }
217
220
 
218
221
  function isRootRelativeUrl(rawUrl) {
219
- return /^\/(?!\/)/.test(rawUrl)
222
+ return /^\/(?!\/)/.test(rawUrl);
220
223
  }
221
224
 
222
225
  function isPathWithinPrefix(pathname, prefix) {
223
- return pathname === prefix.replace(/\/$/, '') || pathname.startsWith(prefix)
226
+ return pathname === prefix.replace(/\/$/, '') || pathname.startsWith(prefix);
224
227
  }