@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.
- package/README.md +10 -0
- package/bin/readme.js +1 -1
- package/package.json +34 -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 +2263 -2066
- package/src/commands/lint.js +33 -29
- package/src/commands/oas-bundle.js +89 -0
- 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 -158
- 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
package/src/commands/lint.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
import { printHeader, isAgenticCli } from '../utils/eyes.js';
|
|
3
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
4
|
+
import { collectFiles, runValidators } from '../utils/lint.js';
|
|
5
|
+
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js';
|
|
6
|
+
import * as styles from '../utils/styles.js';
|
|
7
7
|
|
|
8
|
-
export const command = 'lint'
|
|
9
|
-
export const order = 1
|
|
10
|
-
export const aliases = ['validate']
|
|
11
|
-
export const category = 'Linting'
|
|
12
|
-
export const description = 'Lint and validate your ReadMe docs'
|
|
8
|
+
export const command = 'lint';
|
|
9
|
+
export const order = 1;
|
|
10
|
+
export const aliases = ['validate'];
|
|
11
|
+
export const category = 'Linting';
|
|
12
|
+
export const description = 'Lint and validate your ReadMe docs';
|
|
13
13
|
|
|
14
14
|
export function args(cmd) {
|
|
15
|
-
cmd.option('--json', 'Output results as JSON (for CI and automation)')
|
|
16
|
-
cmd.option('--github', 'Output a GitHub PR comment body as markdown')
|
|
17
|
-
cmd.option('--fix', 'Automatically fix issues where possible')
|
|
15
|
+
cmd.option('--json', 'Output results as JSON (for CI and automation)');
|
|
16
|
+
cmd.option('--github', 'Output a GitHub PR comment body as markdown');
|
|
17
|
+
cmd.option('--fix', 'Automatically fix issues where possible');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -31,37 +31,41 @@ export function args(cmd) {
|
|
|
31
31
|
* @returns {Promise<{ gitRoot: string, files: string[], results: object[], hasErrors: boolean }>}
|
|
32
32
|
*/
|
|
33
33
|
export async function lint({ cwd, fix = false, nonInteractive = false, onFile, onBeforeCrossFile } = {}) {
|
|
34
|
-
const gitRoot = cwd || process.cwd()
|
|
35
|
-
const files = collectFiles(gitRoot)
|
|
36
|
-
const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive })
|
|
37
|
-
const hasErrors = results.some(
|
|
38
|
-
return { gitRoot, files, results, hasErrors }
|
|
34
|
+
const gitRoot = cwd || process.cwd();
|
|
35
|
+
const files = collectFiles(gitRoot);
|
|
36
|
+
const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive });
|
|
37
|
+
const hasErrors = results.some(r => r.severity !== 'warning');
|
|
38
|
+
return { gitRoot, files, results, hasErrors };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export async function run(options, _cmd, ctx) {
|
|
42
|
-
const { gitRoot } = ctx
|
|
42
|
+
const { gitRoot } = ctx;
|
|
43
43
|
|
|
44
44
|
if (!options.json && !options.github && !isAgenticCli()) {
|
|
45
|
-
printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
|
|
46
|
-
console.log()
|
|
45
|
+
printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
|
|
46
|
+
console.log();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const reporter = options.github
|
|
49
|
+
const reporter = options.github
|
|
50
|
+
? createGithubReporter()
|
|
51
|
+
: options.json
|
|
52
|
+
? createJsonReporter()
|
|
53
|
+
: createHumanReporter();
|
|
50
54
|
|
|
51
55
|
const { files, results, hasErrors } = await lint({
|
|
52
56
|
cwd: gitRoot,
|
|
53
57
|
fix: options.fix,
|
|
54
|
-
onFile:
|
|
58
|
+
onFile: f => reporter.onFile(f),
|
|
55
59
|
onBeforeCrossFile: () => reporter.pause(),
|
|
56
|
-
})
|
|
60
|
+
});
|
|
57
61
|
|
|
58
|
-
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
|
|
62
|
+
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot });
|
|
59
63
|
|
|
60
|
-
writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
|
|
64
|
+
writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results });
|
|
61
65
|
|
|
62
66
|
if (hasErrors) {
|
|
63
67
|
// Wait for stdout to flush (important when piped to a file), then exit
|
|
64
|
-
await new Promise(
|
|
65
|
-
process.exit(1)
|
|
68
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
69
|
+
process.exit(1);
|
|
66
70
|
}
|
|
67
71
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { bundle } from '@readme/openapi-parser';
|
|
6
|
+
|
|
7
|
+
import * as styles from '../utils/styles.js';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const yaml = require('js-yaml');
|
|
11
|
+
|
|
12
|
+
export const command = 'oas:bundle';
|
|
13
|
+
export const order = 4;
|
|
14
|
+
export const category = 'OAS Tooling';
|
|
15
|
+
export const description = 'Bundle a multi-file OpenAPI spec into a single file';
|
|
16
|
+
// The input spec usually lives outside a ReadMe docs repo (e.g. the API's
|
|
17
|
+
// source repo in CI), so don't require a version branch or docs/ dir.
|
|
18
|
+
export const skipBootstrap = true;
|
|
19
|
+
|
|
20
|
+
export function args(cmd) {
|
|
21
|
+
cmd
|
|
22
|
+
.argument('<spec>', 'Path to the root OpenAPI spec file (JSON or YAML)')
|
|
23
|
+
.option('-o, --out <path>', 'Write the bundled spec to this file instead of stdout');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve external $refs into one document with only internal (#/...) pointers. */
|
|
27
|
+
export async function bundleOas({ file, cwd } = {}) {
|
|
28
|
+
const specPath = path.resolve(cwd || process.cwd(), file);
|
|
29
|
+
if (!fs.existsSync(specPath)) {
|
|
30
|
+
throw new Error(`Spec file not found: ${specPath}`);
|
|
31
|
+
}
|
|
32
|
+
// Pass the path, not contents: openapi-parser then resolves relative $refs
|
|
33
|
+
// against the file's directory. oas-normalize reads first and loses that.
|
|
34
|
+
return bundle(specPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatForPath(outPath) {
|
|
38
|
+
return /\.ya?ml$/i.test(outPath) ? 'yaml' : 'json';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serializeSpec(spec, format) {
|
|
42
|
+
if (format === 'yaml') return yaml.dump(spec, { noRefs: true, lineWidth: -1 });
|
|
43
|
+
return `${JSON.stringify(spec, null, 2)}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Bundle `file` and write it to `out` (format follows the extension). */
|
|
47
|
+
export async function writeBundle({ file, out, cwd } = {}) {
|
|
48
|
+
const root = cwd || process.cwd();
|
|
49
|
+
const spec = await bundleOas({ file, cwd: root });
|
|
50
|
+
const outPath = path.resolve(root, out);
|
|
51
|
+
const format = formatForPath(outPath);
|
|
52
|
+
|
|
53
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
54
|
+
fs.writeFileSync(outPath, serializeSpec(spec, format));
|
|
55
|
+
|
|
56
|
+
return { spec, outPath, format };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function run(specArg, options) {
|
|
60
|
+
try {
|
|
61
|
+
if (options.out) {
|
|
62
|
+
const { spec, outPath } = await writeBundle({ file: specArg, out: options.out });
|
|
63
|
+
const opCount = countOperations(spec);
|
|
64
|
+
const rel = path.relative(process.cwd(), outPath);
|
|
65
|
+
const shown = rel && !rel.startsWith('..') ? rel : outPath;
|
|
66
|
+
styles.ok(
|
|
67
|
+
`Bundled ${styles.bold(spec.info?.title || specArg)} (${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'}) → ${shown}`,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const spec = await bundleOas({ file: specArg });
|
|
73
|
+
process.stdout.write(serializeSpec(spec, formatForPath(specArg)));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
styles.error(`Bundle failed: ${err.message || String(err)}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
81
|
+
|
|
82
|
+
function countOperations(spec) {
|
|
83
|
+
let n = 0;
|
|
84
|
+
for (const item of Object.values(spec.paths || {})) {
|
|
85
|
+
if (!item || typeof item !== 'object') continue;
|
|
86
|
+
for (const key of Object.keys(item)) if (HTTP_METHODS.has(key)) n += 1;
|
|
87
|
+
}
|
|
88
|
+
return n;
|
|
89
|
+
}
|
package/src/commands/oas-sync.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import matter from 'gray-matter';
|
|
5
|
-
import * as styles from '../utils/styles.js';
|
|
6
|
-
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
7
|
+
import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
|
|
8
|
+
import * as styles from '../utils/styles.js';
|
|
10
9
|
|
|
11
10
|
export const command = 'oas:sync';
|
|
12
11
|
export const order = 2;
|
|
@@ -15,6 +14,9 @@ export const description = 'Sync reference pages with OpenAPI specs';
|
|
|
15
14
|
|
|
16
15
|
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
17
16
|
|
|
17
|
+
// js-yaml 5's CORE_SCHEMA omits YAML merge keys (`<<: *anchor`). OpenAPI specs commonly
|
|
18
|
+
// reuse path items via merges — keep that working when reading YAML specs.
|
|
19
|
+
const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Find OAS files at the root of reference/ (JSON or YAML).
|
|
@@ -30,7 +32,7 @@ export function findOasFiles(refDir) {
|
|
|
30
32
|
|
|
31
33
|
try {
|
|
32
34
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
33
|
-
const parsed = entry.endsWith('.json') ? JSON.parse(raw) :
|
|
35
|
+
const parsed = entry.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
|
|
34
36
|
|
|
35
37
|
if (parsed && (parsed.openapi || parsed.swagger)) {
|
|
36
38
|
oasFiles.push({ filename: entry, spec: parsed });
|
|
@@ -139,7 +141,7 @@ function resolveXInternal(operation, spec) {
|
|
|
139
141
|
* `webhook POST newPet`) for lint to warn about.
|
|
140
142
|
*/
|
|
141
143
|
export function findIgnoredInternalExtensions(spec) {
|
|
142
|
-
const hasInternal =
|
|
144
|
+
const hasInternal = obj => {
|
|
143
145
|
const xReadme = obj?.['x-readme'];
|
|
144
146
|
return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme;
|
|
145
147
|
};
|
|
@@ -147,7 +149,10 @@ export function findIgnoredInternalExtensions(spec) {
|
|
|
147
149
|
const locations = [];
|
|
148
150
|
if (hasInternal(spec)) locations.push('root');
|
|
149
151
|
|
|
150
|
-
for (const [entries, isWebhook] of [
|
|
152
|
+
for (const [entries, isWebhook] of [
|
|
153
|
+
[spec?.paths, false],
|
|
154
|
+
[spec?.webhooks, true],
|
|
155
|
+
]) {
|
|
151
156
|
for (const [name, rawItem] of Object.entries(entries || {})) {
|
|
152
157
|
for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) {
|
|
153
158
|
if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue;
|
|
@@ -199,7 +204,7 @@ export function extractOperations(spec) {
|
|
|
199
204
|
summary: operation.summary || null,
|
|
200
205
|
description: operation.description || null,
|
|
201
206
|
// The platform groups by the first *non-empty* tag.
|
|
202
|
-
tag: (Array.isArray(operation.tags) && operation.tags.find(
|
|
207
|
+
tag: (Array.isArray(operation.tags) && operation.tags.find(t => t)) || null,
|
|
203
208
|
path: pathStr,
|
|
204
209
|
isWebhook,
|
|
205
210
|
xInternal: resolveXInternal(operation, spec),
|
|
@@ -228,19 +233,15 @@ export function collectExistingPages(refDir) {
|
|
|
228
233
|
if (stat.isDirectory()) {
|
|
229
234
|
walk(full);
|
|
230
235
|
} else if (entry.endsWith('.md')) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
} catch {
|
|
243
|
-
// Skip unparseable files.
|
|
236
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
237
|
+
const { data } = parseFrontmatter(content);
|
|
238
|
+
if (data.api && data.api.file && data.api.operationId) {
|
|
239
|
+
pages.push({
|
|
240
|
+
filePath: full,
|
|
241
|
+
relativePath: path.relative(refDir, full),
|
|
242
|
+
data,
|
|
243
|
+
content,
|
|
244
|
+
});
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
}
|
|
@@ -259,13 +260,18 @@ function yamlSafeSlug(slug) {
|
|
|
259
260
|
function parseOrderYaml(content) {
|
|
260
261
|
return content
|
|
261
262
|
.split('\n')
|
|
262
|
-
.map(
|
|
263
|
-
.filter(
|
|
264
|
-
.map(
|
|
263
|
+
.map(line => line.trim())
|
|
264
|
+
.filter(line => line.startsWith('- '))
|
|
265
|
+
.map(line =>
|
|
266
|
+
line
|
|
267
|
+
.slice(2)
|
|
268
|
+
.trim()
|
|
269
|
+
.replace(/^["'](.+)["']$/, '$1'),
|
|
270
|
+
);
|
|
265
271
|
}
|
|
266
272
|
|
|
267
273
|
function writeOrderYaml(filePath, slugs) {
|
|
268
|
-
const content = slugs.map(
|
|
274
|
+
const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
|
|
269
275
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
270
276
|
fs.writeFileSync(filePath, content);
|
|
271
277
|
}
|
|
@@ -286,7 +292,7 @@ function addToOrder(orderPath, slug) {
|
|
|
286
292
|
function removeFromOrder(orderPath, slug) {
|
|
287
293
|
if (!fs.existsSync(orderPath)) return;
|
|
288
294
|
const content = fs.readFileSync(orderPath, 'utf-8');
|
|
289
|
-
const slugs = parseOrderYaml(content).filter(
|
|
295
|
+
const slugs = parseOrderYaml(content).filter(s => s !== slug);
|
|
290
296
|
if (slugs.length > 0) {
|
|
291
297
|
writeOrderYaml(orderPath, slugs);
|
|
292
298
|
} else {
|
|
@@ -306,19 +312,7 @@ function safeSegment(value, fallback) {
|
|
|
306
312
|
|
|
307
313
|
function isWithin(baseDir, target) {
|
|
308
314
|
const rel = path.relative(baseDir, target);
|
|
309
|
-
return (
|
|
310
|
-
rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Render a frontmatter-only page. `matter.stringify` always appends a blank
|
|
316
|
-
* body after the closing fence (even for an empty body); the platform's own
|
|
317
|
-
* generated pages end immediately after the fence with no trailing newline,
|
|
318
|
-
* so trim it to match.
|
|
319
|
-
*/
|
|
320
|
-
function stringifyFrontmatter(frontmatter) {
|
|
321
|
-
return matter.stringify('', frontmatter).replace(/\n+$/, '');
|
|
315
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
322
316
|
}
|
|
323
317
|
|
|
324
318
|
function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
|
|
@@ -339,7 +333,7 @@ function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false
|
|
|
339
333
|
hidden,
|
|
340
334
|
};
|
|
341
335
|
|
|
342
|
-
return
|
|
336
|
+
return toFrontmatter(frontmatter);
|
|
343
337
|
}
|
|
344
338
|
|
|
345
339
|
/**
|
|
@@ -354,7 +348,7 @@ function buildTagIndexContent(title, description) {
|
|
|
354
348
|
// As with operation pages, upload always stamps hidden: false on new pages.
|
|
355
349
|
frontmatter.hidden = false;
|
|
356
350
|
|
|
357
|
-
return
|
|
351
|
+
return toFrontmatter(frontmatter);
|
|
358
352
|
}
|
|
359
353
|
|
|
360
354
|
/**
|
|
@@ -493,17 +487,16 @@ function reserveSlug(takenSlugs, base) {
|
|
|
493
487
|
/**
|
|
494
488
|
* Rewrite a page's frontmatter in place, keeping its body. `mutate` receives
|
|
495
489
|
* a copy of the parsed frontmatter and edits it. Returns true if the file
|
|
496
|
-
* changed.
|
|
497
|
-
* so mutating the returned `data` would poison later parses of that content.
|
|
490
|
+
* changed.
|
|
498
491
|
*/
|
|
499
492
|
function updateFrontmatter(filePath, mutate) {
|
|
500
493
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
501
|
-
const parsed =
|
|
494
|
+
const parsed = parseFrontmatter(content);
|
|
495
|
+
// Refuse to rewrite from empty `data` when YAML failed to parse.
|
|
496
|
+
if (frontmatterWriteError(parsed)) return false;
|
|
502
497
|
const data = structuredClone(parsed.data);
|
|
503
498
|
mutate(data);
|
|
504
|
-
const next = parsed.content.trim()
|
|
505
|
-
? matter.stringify(parsed.content, data)
|
|
506
|
-
: stringifyFrontmatter(data);
|
|
499
|
+
const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
|
|
507
500
|
if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
|
|
508
501
|
fs.writeFileSync(filePath, next);
|
|
509
502
|
return true;
|
|
@@ -527,7 +520,7 @@ export function applyOASOrder(currentOrder, orderedSlugs) {
|
|
|
527
520
|
const order = [...new Set(currentOrder)];
|
|
528
521
|
if (!order.length) return desired;
|
|
529
522
|
|
|
530
|
-
const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(
|
|
523
|
+
const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(i => i > -1);
|
|
531
524
|
if (!slots.length) return [...order, ...desired];
|
|
532
525
|
|
|
533
526
|
const remaining = [...desired];
|
|
@@ -561,21 +554,17 @@ const INDEX_FILES = ['index.md', 'index.mdx', 'index.html'];
|
|
|
561
554
|
* deleting it. A stale page can be removed by hand; a deleted edit is gone.
|
|
562
555
|
*/
|
|
563
556
|
function isGeneratedTagIndex(indexPath, specDescriptions) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
parsed = matter(fs.readFileSync(indexPath, 'utf-8'));
|
|
567
|
-
} catch {
|
|
568
|
-
return false;
|
|
569
|
-
}
|
|
570
|
-
const { data, content } = parsed;
|
|
571
|
-
if (content.trim()) return false;
|
|
557
|
+
const { data, content, error } = parseFrontmatter(fs.readFileSync(indexPath, 'utf-8'));
|
|
558
|
+
if (error || content.trim()) return false;
|
|
572
559
|
if (typeof data.title !== 'string' || !('hidden' in data)) return false;
|
|
573
|
-
if (Object.keys(data).some(
|
|
560
|
+
if (Object.keys(data).some(k => !['title', 'hidden', 'excerpt'].includes(k))) return false;
|
|
574
561
|
if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false;
|
|
575
562
|
|
|
576
563
|
const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath)));
|
|
577
564
|
const titleSlug = slugifyFolder(data.title);
|
|
578
|
-
return
|
|
565
|
+
return (
|
|
566
|
+
dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug)
|
|
567
|
+
);
|
|
579
568
|
}
|
|
580
569
|
|
|
581
570
|
/**
|
|
@@ -592,12 +581,12 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
592
581
|
} catch {
|
|
593
582
|
return;
|
|
594
583
|
}
|
|
595
|
-
const others = entries.filter(
|
|
584
|
+
const others = entries.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
|
|
596
585
|
if (others.length) return;
|
|
597
586
|
|
|
598
587
|
const parentDir = path.dirname(dir);
|
|
599
588
|
const slug = path.basename(dir);
|
|
600
|
-
const indexFile = entries.find(
|
|
589
|
+
const indexFile = entries.find(e => INDEX_FILES.includes(e));
|
|
601
590
|
|
|
602
591
|
if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
|
|
603
592
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -625,10 +614,7 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
|
|
|
625
614
|
*/
|
|
626
615
|
function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
627
616
|
const specOps = extractOperations(spec);
|
|
628
|
-
const infoTitle = safeSegment(
|
|
629
|
-
spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
|
|
630
|
-
'api',
|
|
631
|
-
);
|
|
617
|
+
const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
|
|
632
618
|
|
|
633
619
|
// Hyphen vs. space in a group's folder name is not a meaningful difference
|
|
634
620
|
// — "shipping-labels" and "shipping labels" are the same folder to a
|
|
@@ -639,16 +625,11 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
639
625
|
const apiDir = path.join(refDir, infoTitle);
|
|
640
626
|
const foldersBySlug = existingFoldersBySlug(apiDir);
|
|
641
627
|
|
|
642
|
-
const existingPages = collectExistingPages(refDir).filter(
|
|
643
|
-
(p) => p.data.api.file === oasFilename,
|
|
644
|
-
);
|
|
628
|
+
const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
|
|
645
629
|
|
|
646
630
|
const pagesByOpId = new Map();
|
|
647
631
|
for (const page of existingPages) {
|
|
648
|
-
pagesByOpId.set(
|
|
649
|
-
operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
|
|
650
|
-
page,
|
|
651
|
-
);
|
|
632
|
+
pagesByOpId.set(operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }), page);
|
|
652
633
|
}
|
|
653
634
|
|
|
654
635
|
const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] };
|
|
@@ -661,9 +642,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
661
642
|
// Tag descriptions from the spec's top-level `tags` array, used for the
|
|
662
643
|
// per-tag category landing page (index.md).
|
|
663
644
|
const tagDescriptions = new Map(
|
|
664
|
-
(Array.isArray(spec.tags) ? spec.tags : [])
|
|
665
|
-
.filter((t) => t && t.name)
|
|
666
|
-
.map((t) => [t.name, t.description || null]),
|
|
645
|
+
(Array.isArray(spec.tags) ? spec.tags : []).filter(t => t && t.name).map(t => [t.name, t.description || null]),
|
|
667
646
|
);
|
|
668
647
|
// Every description the spec supplies, regardless of which tag: what an
|
|
669
648
|
// emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`).
|
|
@@ -714,13 +693,34 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
714
693
|
// listed in `tags`) keeps its natural encounter order, appended after every
|
|
715
694
|
// declared tag.
|
|
716
695
|
const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
|
|
717
|
-
.filter(
|
|
718
|
-
.map(
|
|
696
|
+
.filter(t => t && t.name)
|
|
697
|
+
.map(t => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
|
|
719
698
|
const orderedFolders = [
|
|
720
|
-
...declaredOrder.filter(
|
|
721
|
-
...[...groupsByFolder.keys()].filter(
|
|
699
|
+
...declaredOrder.filter(folder => groupsByFolder.has(folder)),
|
|
700
|
+
...[...groupsByFolder.keys()].filter(folder => !declaredOrder.includes(folder)),
|
|
722
701
|
];
|
|
723
702
|
|
|
703
|
+
// Groups that will hold at least one of this spec's pages after this run:
|
|
704
|
+
// a new page is created in its folder, an existing page already lives
|
|
705
|
+
// anywhere under it (a direct child, or nested in a hand-made subfolder),
|
|
706
|
+
// or `apply-tag-changes` will move one in (see the move pass below). A group
|
|
707
|
+
// whose pages were all moved somewhere else (another category, or a folder
|
|
708
|
+
// spelled differently) gets no category page — creating one would leave an
|
|
709
|
+
// empty index.md with no children.
|
|
710
|
+
const populatedFolders = new Set();
|
|
711
|
+
for (const [key, op] of specOps) {
|
|
712
|
+
const { folder } = operationGroup(op);
|
|
713
|
+
const page = pagesByOpId.get(key);
|
|
714
|
+
const tagDir = path.join(apiDir, foldersBySlug.get(folder) || folder);
|
|
715
|
+
if (
|
|
716
|
+
!page ||
|
|
717
|
+
isWithin(tagDir, page.filePath) ||
|
|
718
|
+
(applyTagChanges && isWithin(apiDir, page.filePath) && path.basename(page.filePath) !== 'index.md')
|
|
719
|
+
) {
|
|
720
|
+
populatedFolders.add(folder);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
724
|
const groupDirs = new Map();
|
|
725
725
|
const createdIndexes = new Set();
|
|
726
726
|
for (const folder of orderedFolders) {
|
|
@@ -728,9 +728,11 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
728
728
|
const actualFolder = foldersBySlug.get(folder) || folder;
|
|
729
729
|
const pageDir = path.join(refDir, infoTitle, actualFolder);
|
|
730
730
|
if (!isWithin(refDir, pageDir)) continue;
|
|
731
|
-
groupDirs.set(folder, pageDir);
|
|
732
731
|
|
|
733
732
|
const indexPath = path.join(pageDir, 'index.md');
|
|
733
|
+
if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
|
|
734
|
+
groupDirs.set(folder, pageDir);
|
|
735
|
+
|
|
734
736
|
if (!fs.existsSync(indexPath)) {
|
|
735
737
|
// Never overwrite an existing index.md — it may be a hand-written category.
|
|
736
738
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
@@ -745,7 +747,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
745
747
|
// With `apply-tag-changes`, the spec owns the category page's title and
|
|
746
748
|
// excerpt too (a tag with no description clears the excerpt). Its body
|
|
747
749
|
// and any other frontmatter are the user's and are kept.
|
|
748
|
-
const updated = updateFrontmatter(indexPath,
|
|
750
|
+
const updated = updateFrontmatter(indexPath, data => {
|
|
749
751
|
data.title = title;
|
|
750
752
|
if (description) data.excerpt = description;
|
|
751
753
|
else delete data.excerpt;
|
|
@@ -801,7 +803,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
801
803
|
// its own `hidden` — removing the extension never unhides a page.
|
|
802
804
|
if (op.xInternal.present) {
|
|
803
805
|
const hidden = Boolean(op.xInternal.value);
|
|
804
|
-
const updated = updateFrontmatter(filePath,
|
|
806
|
+
const updated = updateFrontmatter(filePath, data => {
|
|
805
807
|
data.hidden = hidden;
|
|
806
808
|
});
|
|
807
809
|
if (updated) changes.updated.push(path.relative(refDir, filePath));
|
|
@@ -861,9 +863,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
861
863
|
const indexPath = path.join(dir, 'index.md');
|
|
862
864
|
if (!fs.existsSync(indexPath)) continue;
|
|
863
865
|
const isNew = createdIndexes.has(indexPath);
|
|
864
|
-
const allHidden = children.every(
|
|
866
|
+
const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
|
|
865
867
|
if (!allHidden) continue;
|
|
866
|
-
const updated = updateFrontmatter(indexPath,
|
|
868
|
+
const updated = updateFrontmatter(indexPath, data => {
|
|
867
869
|
data.hidden = true;
|
|
868
870
|
});
|
|
869
871
|
if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
|
|
@@ -925,7 +927,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
925
927
|
* Returns null if there's no reference/ dir or no specs.
|
|
926
928
|
*/
|
|
927
929
|
export function syncOas(input) {
|
|
928
|
-
const gitRoot = typeof input === 'string' ? input :
|
|
930
|
+
const gitRoot = typeof input === 'string' ? input : input?.cwd || process.cwd();
|
|
929
931
|
const refDir = path.join(gitRoot, 'reference');
|
|
930
932
|
if (!fs.existsSync(refDir)) return null;
|
|
931
933
|
|
|
@@ -977,7 +979,9 @@ export function printSyncResults(results) {
|
|
|
977
979
|
|
|
978
980
|
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
979
981
|
console.log();
|
|
980
|
-
console.log(
|
|
982
|
+
console.log(
|
|
983
|
+
` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`,
|
|
984
|
+
);
|
|
981
985
|
|
|
982
986
|
if (!hasChanges) {
|
|
983
987
|
continue;
|
|
@@ -996,9 +1000,7 @@ export function printSyncResults(results) {
|
|
|
996
1000
|
console.log(` ${styles.warn('~')} Updated ${file}`);
|
|
997
1001
|
}
|
|
998
1002
|
for (const { path: file, operationId } of changes.skipped) {
|
|
999
|
-
console.log(
|
|
1000
|
-
` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
|
|
1001
|
-
);
|
|
1003
|
+
console.log(` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`);
|
|
1002
1004
|
skipped.push({ filename, path: file, operationId });
|
|
1003
1005
|
}
|
|
1004
1006
|
|
|
@@ -1046,8 +1048,7 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1046
1048
|
return;
|
|
1047
1049
|
}
|
|
1048
1050
|
|
|
1049
|
-
const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } =
|
|
1050
|
-
printSyncResults(results);
|
|
1051
|
+
const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = printSyncResults(results);
|
|
1051
1052
|
|
|
1052
1053
|
console.log();
|
|
1053
1054
|
const total = totalAdded + totalDeleted + totalMoved + totalUpdated;
|
|
@@ -1064,7 +1065,9 @@ export async function run(_options, _cmd, ctx) {
|
|
|
1064
1065
|
// to whoever wraps this command in CI to notice and fail on it themselves.
|
|
1065
1066
|
if (totalSkipped > 0) {
|
|
1066
1067
|
const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : '';
|
|
1067
|
-
styles.error(
|
|
1068
|
+
styles.error(
|
|
1069
|
+
`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`,
|
|
1070
|
+
);
|
|
1068
1071
|
} else if (total === 0) {
|
|
1069
1072
|
styles.ok('Reference pages are already in sync.');
|
|
1070
1073
|
} else {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
|
|
3
4
|
import OASNormalize from 'oas-normalize';
|
|
4
|
-
|
|
5
|
-
import * as styles from '../utils/styles.js';
|
|
5
|
+
|
|
6
6
|
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
7
|
+
import * as styles from '../utils/styles.js';
|
|
8
|
+
|
|
9
|
+
import { findOasFiles, extractOperations } from './oas-sync.js';
|
|
7
10
|
|
|
8
11
|
export const command = 'oas:validate';
|
|
9
12
|
export const order = 3;
|
|
@@ -114,7 +117,7 @@ export async function validateOas({ cwd, dereference = false } = {}) {
|
|
|
114
117
|
continue;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
const errors = normalizerResult.valid ? [] :
|
|
120
|
+
const errors = normalizerResult.valid ? [] : normalizerResult.errors || [];
|
|
118
121
|
const warnings = normalizerResult.warnings || [];
|
|
119
122
|
|
|
120
123
|
if (dereference) {
|
|
@@ -158,9 +161,7 @@ export async function validateOasFiles(gitRoot, { dereference = false } = {}) {
|
|
|
158
161
|
|
|
159
162
|
for (const r of summary.results) {
|
|
160
163
|
const meta = `${r.filename} · ${r.version} · ${r.opCount} ${r.opCount === 1 ? 'endpoint' : 'endpoints'}`;
|
|
161
|
-
const dot = r.errors.length > 0
|
|
162
|
-
? styles.err('●')
|
|
163
|
-
: r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
|
|
164
|
+
const dot = r.errors.length > 0 ? styles.err('●') : r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
|
|
164
165
|
|
|
165
166
|
console.log();
|
|
166
167
|
console.log(` ${dot} ${styles.bold(r.title)} ${styles.dim(`(${meta})`)}`);
|
|
@@ -224,7 +225,9 @@ export async function run(options, _cmd, ctx) {
|
|
|
224
225
|
styles.error(`${parts.join(' and ')} across ${fileCount} ${fileCount === 1 ? 'spec' : 'specs'}.`);
|
|
225
226
|
process.exit(1);
|
|
226
227
|
} else if (totalWarnings > 0) {
|
|
227
|
-
styles.warning(
|
|
228
|
+
styles.warning(
|
|
229
|
+
`${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`,
|
|
230
|
+
);
|
|
228
231
|
} else {
|
|
229
232
|
styles.ok(`${fileCount} ${fileCount === 1 ? 'spec' : 'specs'} validated — all good!`);
|
|
230
233
|
}
|