@readme/cli 0.1.0 → 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.
- package/bin/readme.js +1 -1
- package/package.json +33 -24
- package/src/bootstrap.js +9 -13
- package/src/cli.js +17 -11
- package/src/commands/components.js +35 -18
- package/src/commands/dev.js +12 -8
- package/src/commands/eyes.js +12 -8
- package/src/commands/import.js +2190 -2048
- package/src/commands/lint.js +33 -29
- package/src/commands/oas-bundle.js +6 -2
- package/src/commands/oas-sync.js +96 -93
- package/src/commands/oas-validate.js +10 -7
- package/src/commands/pretty.js +177 -164
- package/src/commands/setup.js +24 -26
- package/src/commands/versions.js +6 -2
- package/src/dev/app/[...slug]/page.js +1 -0
- package/src/dev/app/layout.js +18 -11
- package/src/dev/app/page.js +1 -0
- package/src/dev/lib/docs.js +27 -19
- package/src/dev/next.config.mjs +2 -2
- package/src/gha.js +7 -7
- package/src/prompts/index.js +16 -6
- package/src/utils/eyes.js +143 -51
- package/src/utils/git.js +3 -3
- package/src/utils/lint.js +39 -38
- package/src/utils/llms.js +94 -91
- package/src/utils/markdown.js +256 -0
- package/src/utils/marketplace.js +12 -5
- package/src/utils/multi-spinner.js +5 -7
- package/src/utils/promise-pool.js +7 -5
- package/src/utils/reporter.js +88 -91
- package/src/utils/setup-templates.js +12 -6
- package/src/utils/styles.js +2 -1
- package/src/utils/tamagotchi.js +257 -91
- package/src/utils/tips.js +18 -9
- package/src/utils/url-segs.js +46 -41
- package/src/validators/components.js +55 -63
- package/src/validators/content.js +5 -10
- package/src/validators/duplicates.js +6 -2
- package/src/validators/frontmatter.js +19 -21
- package/src/validators/links.js +4 -8
- package/src/validators/oas-reference.js +7 -14
- package/src/validators/oas-schema.js +2 -0
- package/src/validators/ordering.js +5 -5
- package/src/validators/recipes.js +4 -12
- package/src/commands/import-oas.test.js +0 -277
- package/src/commands/import.classify.test.js +0 -172
- package/src/commands/import.landing.test.js +0 -217
- package/src/commands/import.slug-planner.test.js +0 -86
- package/src/commands/import.staging.test.js +0 -230
- package/src/commands/import.test.js +0 -382
- package/src/utils/llms.test.js +0 -302
- 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
|
+
}
|
package/src/utils/marketplace.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
77
|
-
.map(
|
|
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(
|
|
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)) {
|
|
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
|
|
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] ?? (
|
|
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) =>
|
|
12
|
-
|
|
13
|
-
|
|
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
|
}
|