@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
@@ -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
  }