@readme/cli 0.1.0 → 0.3.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 (57) hide show
  1. package/bin/git-format.js +80 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +54 -26
  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 +2200 -2049
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +6 -2
  12. package/src/commands/oas-sync.js +271 -161
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +188 -165
  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/containment.js +87 -0
  25. package/src/utils/eyes.js +143 -51
  26. package/src/utils/git.js +3 -3
  27. package/src/utils/lint.js +39 -38
  28. package/src/utils/llms.js +94 -91
  29. package/src/utils/markdown.js +256 -0
  30. package/src/utils/marketplace.js +12 -5
  31. package/src/utils/multi-spinner.js +5 -7
  32. package/src/utils/promise-pool.js +7 -5
  33. package/src/utils/reporter.js +88 -94
  34. package/src/utils/setup-templates.js +12 -6
  35. package/src/utils/styles.js +2 -1
  36. package/src/utils/tamagotchi.js +257 -91
  37. package/src/utils/tips.js +18 -9
  38. package/src/utils/url-segs.js +46 -41
  39. package/src/validators/components.js +55 -63
  40. package/src/validators/content.js +16 -14
  41. package/src/validators/duplicates.js +6 -2
  42. package/src/validators/frontmatter.js +21 -29
  43. package/src/validators/links.js +4 -8
  44. package/src/validators/oas-reference.js +25 -18
  45. package/src/validators/oas-schema.js +2 -0
  46. package/src/validators/ordering.js +5 -5
  47. package/src/validators/recipes.js +4 -12
  48. package/vendor/git-format/CLAUDE.md +352 -0
  49. package/vendor/git-format/frontmatter.schema.json +199 -0
  50. package/src/commands/import-oas.test.js +0 -277
  51. package/src/commands/import.classify.test.js +0 -172
  52. package/src/commands/import.landing.test.js +0 -217
  53. package/src/commands/import.slug-planner.test.js +0 -86
  54. package/src/commands/import.staging.test.js +0 -230
  55. package/src/commands/import.test.js +0 -382
  56. package/src/utils/llms.test.js +0 -302
  57. package/src/utils/url-segs.test.js +0 -239
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
  }
@@ -0,0 +1,256 @@
1
+ import { YAMLException, dump as dumpYAML, load as loadYAML } from 'js-yaml';
2
+ import removeUndefinedObjects from 'remove-undefined-objects';
3
+
4
+ // Each retry rewrites one rejected line and parses again. A real page has a few of these.
5
+ // Ten parses bounds a frontmatter block full of broken lines.
6
+ const MAX_FRONTMATTER_YAML_ATTEMPTS = 10;
7
+
8
+ /** A frontmatter fence line: `---` with optional trailing spaces/tabs. */
9
+ const FENCE_LINE = /^---[ \t]*$/;
10
+
11
+ /**
12
+ * Split a normalized (LF) document that starts with a `---` fence into YAML + body.
13
+ * The closing fence is a line that is exactly `---` (optional trailing spaces) —
14
+ * including adjacent empty headers (`---\n---\n# Body`).
15
+ */
16
+ function splitFrontmatter(normalized) {
17
+ const firstNl = normalized.indexOf('\n');
18
+ if (firstNl === -1) {
19
+ return FENCE_LINE.test(normalized) ? { yamlContent: '', body: '' } : null;
20
+ }
21
+
22
+ const firstLine = normalized.slice(0, firstNl);
23
+ if (!FENCE_LINE.test(firstLine)) {
24
+ return null;
25
+ }
26
+
27
+ const rest = normalized.slice(firstNl + 1);
28
+ const lines = rest.split('\n');
29
+
30
+ // Find the closing fence line (may be the first line for empty headers).
31
+ let closeIdx = -1;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ if (FENCE_LINE.test(lines[i])) {
34
+ closeIdx = i;
35
+ break;
36
+ }
37
+ }
38
+
39
+ if (closeIdx === -1) {
40
+ // Opening fence only — treat the remainder as YAML, empty body
41
+ return { yamlContent: rest, body: '' };
42
+ }
43
+
44
+ const yamlContent = lines.slice(0, closeIdx).join('\n');
45
+ const body = lines.slice(closeIdx + 1).join('\n');
46
+ return { yamlContent, body };
47
+ }
48
+
49
+ // A mapping entry whose value is still a plain scalar: `description: POST : PATCH`.
50
+ // `:[ \t]` matches PLAIN_SCALAR_COLON: space or tab ends a plain scalar.
51
+ const PLAIN_MAPPING_VALUE = /^(\s*)([^\s:#][^:\n]*?)(:[ \t]+)(\S.*)$/;
52
+ const PLAIN_SCALAR_COLON = /:[ \t]/;
53
+
54
+ function removeNullValues(obj) {
55
+ // Check if obj is not an object or is null, return it directly
56
+ if (typeof obj !== 'object' || obj === null) return obj;
57
+
58
+ // Array handling
59
+ if (Array.isArray(obj)) {
60
+ return obj.map(removeNullValues).filter(item => item !== null);
61
+ }
62
+
63
+ // Object handling
64
+ const result = {};
65
+ for (const key in obj) {
66
+ if (Object.hasOwn(obj, key)) {
67
+ const value = removeNullValues(obj[key]);
68
+ if (value !== null) {
69
+ result[key] = value;
70
+ }
71
+ }
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ // This function takes a JSON object and sorts it:
78
+ // First, any key in customOrder
79
+ // Second, alphabetically
80
+ //
81
+ function sortFrontmatterObj(obj, customOrder = []) {
82
+ const sortedObj = {};
83
+
84
+ // Helper function to check if a value is an object
85
+ const isObject = value => value && typeof value === 'object' && !Array.isArray(value);
86
+
87
+ // Add keys in the custom order first
88
+ customOrder.forEach(key => {
89
+ if (key in obj) {
90
+ sortedObj[key] = isObject(obj[key]) ? sortFrontmatterObj(obj[key], customOrder) : obj[key];
91
+ }
92
+ });
93
+
94
+ // Add the rest of the keys in alphabetical order
95
+ Object.keys(obj)
96
+ .sort()
97
+ .forEach(key => {
98
+ if (!customOrder.includes(key)) {
99
+ sortedObj[key] = isObject(obj[key]) ? sortFrontmatterObj(obj[key], customOrder) : obj[key];
100
+ }
101
+ });
102
+
103
+ return sortedObj;
104
+ }
105
+
106
+ /**
107
+ * Quote one plain mapping value that contains `:` followed by space or tab. Returns null when the
108
+ * line is any other shape. A trailing ` #…` stays a comment outside the quotes.
109
+ *
110
+ */
111
+ function quoteUnquotedColonValue(line) {
112
+ const carriageReturn = line.endsWith('\r') ? '\r' : '';
113
+ const source = carriageReturn ? line.slice(0, -1) : line;
114
+ const match = PLAIN_MAPPING_VALUE.exec(source);
115
+ if (!match) return null;
116
+
117
+ const [, indent, key, separator, value] = match;
118
+ if (/^['"|>[{*]/.test(value)) return null;
119
+
120
+ // A YAML comment starts at `#` after a space or tab. Other whitespace, including a
121
+ // non-breaking space, is part of the value.
122
+ const commentAt = value.search(/[ \t]+#/);
123
+ const scalar = commentAt === -1 ? value : value.slice(0, commentAt);
124
+ const comment = commentAt === -1 ? '' : value.slice(commentAt);
125
+ if (!PLAIN_SCALAR_COLON.test(scalar)) return null;
126
+
127
+ return `${indent}${key}${separator}'${scalar.replaceAll("'", "''")}'${comment}${carriageReturn}`;
128
+ }
129
+
130
+ /**
131
+ * Parse frontmatter YAML. When a plain value contains `:` + whitespace, quote the line js-yaml
132
+ * rejected and parse again. Gives up after {@link MAX_FRONTMATTER_YAML_ATTEMPTS} parses.
133
+ *
134
+ */
135
+ function loadFrontmatterYAML(yamlContent) {
136
+ let source = yamlContent;
137
+ const quotedLines = new Set();
138
+
139
+ for (let attempt = 0; ; attempt++) {
140
+ try {
141
+ return loadYAML(source);
142
+ } catch (err) {
143
+ const line =
144
+ err instanceof YAMLException && err.reason === 'bad indentation of a mapping entry'
145
+ ? err.mark?.line
146
+ : undefined;
147
+ if (line === undefined || quotedLines.has(line) || attempt >= MAX_FRONTMATTER_YAML_ATTEMPTS - 1) {
148
+ throw err;
149
+ }
150
+
151
+ const lines = source.split('\n');
152
+ const repaired = quoteUnquotedColonValue(lines[line] ?? '');
153
+ if (repaired === null) throw err;
154
+
155
+ quotedLines.add(line);
156
+ lines[line] = repaired;
157
+ source = lines.join('\n');
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Parse frontmatter from markdown content. Extracts YAML frontmatter between --- delimiters.
164
+ *
165
+ * On YAML failure returns `{ content, data: {}, error }` so callers can soft-fail or report.
166
+ * This function does not throw for invalid YAML.
167
+ *
168
+ */
169
+ export function parseFrontmatter(content) {
170
+ // Strip BOM (Byte Order Mark) if present. Non-frontmatter paths always return this string.
171
+ const withoutBom = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
172
+
173
+ // Allow frontmatter to be preceded by blank lines (common in hand-edited docs)
174
+ const stripped = withoutBom.replace(/^[\r\n]+/, '');
175
+
176
+ // Not frontmatter: keep the original (minus BOM), including leading blank lines
177
+ if (!stripped.startsWith('---') || (stripped.length > 3 && stripped[3] === '-')) {
178
+ return { content: withoutBom, data: {} };
179
+ }
180
+
181
+ // Normalize CRLF (and bare CR) so Windows-saved docs parse like LF files
182
+ const normalized = stripped.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
183
+
184
+ const split = splitFrontmatter(normalized);
185
+ if (!split) {
186
+ return { content: withoutBom, data: {} };
187
+ }
188
+ const { yamlContent, body } = split;
189
+
190
+ // Empty / whitespace-only frontmatter is valid → empty data, no error
191
+ if (!yamlContent.trim()) {
192
+ return { content: body, data: {} };
193
+ }
194
+
195
+ try {
196
+ const parsed = loadFrontmatterYAML(yamlContent);
197
+
198
+ // Non-object YAML (string, number, boolean, array) → empty data
199
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
200
+ return { content: body, data: {} };
201
+ }
202
+
203
+ return { content: body, data: parsed };
204
+ } catch (err) {
205
+ return { content: body, data: {}, error: err };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Guard for write paths. Returns an Error when {@link parseFrontmatter} could not load YAML so
211
+ * callers never rewrite a page from empty `data`. Returns `null` when the parse is safe to write.
212
+ *
213
+ */
214
+ export function frontmatterWriteError(parsed) {
215
+ if (!parsed?.error) return null;
216
+ const detail = parsed.error.reason || parsed.error.message || 'unknown error';
217
+ return new Error(`Invalid frontmatter: ${detail}`, { cause: parsed.error });
218
+ }
219
+
220
+ /**
221
+ * Convert a given object into a frontmatter string. When `body` is provided, append it after the
222
+ * closing fence.
223
+ *
224
+ * @see {@link https://jekyllrb.com/docs/front-matter/}
225
+ */
226
+ export function toFrontmatter(data, body = '') {
227
+ if (data === null) {
228
+ return body || '';
229
+ }
230
+
231
+ // Remove all `null` data from our Frontmatter and then remove any objects that may be undefined.
232
+ // This'll help prevent us from creating empty properties there like `metadata: {}` which
233
+ // wouldn't look very nice.
234
+ let sanitized = removeNullValues(data);
235
+ sanitized = removeUndefinedObjects(sanitized);
236
+ if (sanitized === undefined) {
237
+ return body || '';
238
+ }
239
+
240
+ // Converting an empty object into YAML will create `{}`, which putting into frontmatter will
241
+ // become invalid so if we don't have any data don't create anything.
242
+ if (
243
+ typeof sanitized === 'object' &&
244
+ !Array.isArray(sanitized) &&
245
+ sanitized !== null &&
246
+ !Object.keys(sanitized).length
247
+ ) {
248
+ return body || '';
249
+ }
250
+
251
+ const sorted = sortFrontmatterObj(sanitized, ['title', 'excerpt', 'description']);
252
+ const yaml = dumpYAML(sorted);
253
+ const fence = `---\n${yaml}---`;
254
+
255
+ return body ? `${fence}\n${body}` : fence;
256
+ }
@@ -64,7 +64,11 @@ export async function listMarketplaceComponents({ force = false } = {}) {
64
64
  if (!force) {
65
65
  const cached = readCache(cacheKey);
66
66
  if (cached) {
67
- try { return JSON.parse(cached); } catch { /* fall through */ }
67
+ try {
68
+ return JSON.parse(cached);
69
+ } catch {
70
+ /* fall through */
71
+ }
68
72
  }
69
73
  }
70
74
 
@@ -73,8 +77,8 @@ export async function listMarketplaceComponents({ force = false } = {}) {
73
77
  const entries = JSON.parse(body);
74
78
 
75
79
  const components = entries
76
- .filter((e) => e.type === 'dir' && !e.name.startsWith('.'))
77
- .map((e) => ({
80
+ .filter(e => e.type === 'dir' && !e.name.startsWith('.'))
81
+ .map(e => ({
78
82
  name: e.name,
79
83
  htmlUrl: `https://github.com/${REPO}/tree/${BRANCH}/${COMPONENTS_PATH}/${e.name}`,
80
84
  mdxUrl: `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${COMPONENTS_PATH}/${e.name}/${e.name}.mdx`,
@@ -92,7 +96,7 @@ export async function listMarketplaceComponents({ force = false } = {}) {
92
96
  */
93
97
  export async function fetchMarketplaceComponent(name, { force = false } = {}) {
94
98
  const components = await listMarketplaceComponents({ force });
95
- const match = components.find((c) => c.name.toLowerCase() === name.toLowerCase());
99
+ const match = components.find(c => c.name.toLowerCase() === name.toLowerCase());
96
100
  if (!match) {
97
101
  throw new Error(`Component "${name}" not found in marketplace.`);
98
102
  }
@@ -130,7 +134,10 @@ export function summarizeReadme(readme) {
130
134
  let inOverview = false;
131
135
  for (let i = 0; i < lines.length; i++) {
132
136
  const line = lines[i].trim();
133
- if (/^##\s+overview/i.test(line)) { inOverview = true; continue; }
137
+ if (/^##\s+overview/i.test(line)) {
138
+ inOverview = true;
139
+ continue;
140
+ }
134
141
  if (inOverview && line && !line.startsWith('#') && !line.startsWith('![')) {
135
142
  return line.replace(/[*_`]/g, '').slice(0, 140);
136
143
  }
@@ -1,6 +1,6 @@
1
- import { createLogUpdate } from 'log-update';
2
- import cliSpinners from 'cli-spinners';
3
1
  import chalk from 'chalk';
2
+ import cliSpinners from 'cli-spinners';
3
+ import { createLogUpdate } from 'log-update';
4
4
 
5
5
  /**
6
6
  * Multi-row spinner. Renders `rows` labeled lines, repainted in unison at the
@@ -42,7 +42,7 @@ export class MultiSpinner {
42
42
  if (this.isTTY && this.update) {
43
43
  this.update.persist(text);
44
44
  } else {
45
- this.stream.write(text + '\n');
45
+ this.stream.write(`${text}\n`);
46
46
  }
47
47
  }
48
48
 
@@ -61,10 +61,8 @@ export class MultiSpinner {
61
61
  _render() {
62
62
  if (!this.update) return;
63
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');
64
+ const colorize = chalk[this.color] ?? (s => s);
65
+ const out = this.lines.map(text => (text ? `${colorize(glyph)} ${text}` : '')).join('\n');
68
66
  this.update(out);
69
67
  this.frame++;
70
68
  }
@@ -8,10 +8,12 @@ export async function runPool(items, concurrency, worker) {
8
8
  let cursor = 0;
9
9
  const next = () => (cursor < items.length ? cursor++ : -1);
10
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
- })());
11
+ const workers = Array.from({ length: lanes }, (_, workerIdx) =>
12
+ (async () => {
13
+ for (let i = next(); i !== -1; i = next()) {
14
+ await worker(items[i], i, workerIdx);
15
+ }
16
+ })(),
17
+ );
16
18
  await Promise.all(workers);
17
19
  }