@readme/cli 0.0.30 → 0.1.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 +37 -0
- package/package.json +7 -2
- package/src/commands/import.js +128 -73
- package/src/commands/import.staging.test.js +74 -2
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-bundle.js +85 -0
- package/src/commands/oas-sync.js +501 -48
- package/src/commands/oas-validate.js +23 -0
- package/src/gha-pre.js +29 -0
- package/src/gha.js +104 -0
- package/src/utils/gha-output.js +32 -0
- package/src/utils/lint.js +30 -15
- package/src/validators/oas-reference.js +24 -3
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import OASNormalize from 'oas-normalize';
|
|
4
4
|
import { findOasFiles, extractOperations } from './oas-sync.js';
|
|
5
5
|
import * as styles from '../utils/styles.js';
|
|
6
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
6
7
|
|
|
7
8
|
export const command = 'oas:validate';
|
|
8
9
|
export const order = 3;
|
|
@@ -182,6 +183,13 @@ export async function run(options, _cmd, ctx) {
|
|
|
182
183
|
|
|
183
184
|
if (!fs.existsSync(refDir)) {
|
|
184
185
|
styles.error('No reference/ directory found.');
|
|
186
|
+
writeGithubActionsOutputs({
|
|
187
|
+
'has-errors': 'true',
|
|
188
|
+
'total-errors': '0',
|
|
189
|
+
'total-warnings': '0',
|
|
190
|
+
'total-valid': '0',
|
|
191
|
+
'file-count': '0',
|
|
192
|
+
});
|
|
185
193
|
process.exit(1);
|
|
186
194
|
}
|
|
187
195
|
|
|
@@ -189,11 +197,26 @@ export async function run(options, _cmd, ctx) {
|
|
|
189
197
|
|
|
190
198
|
if (!result) {
|
|
191
199
|
styles.info('No OpenAPI spec files found in reference/.');
|
|
200
|
+
writeGithubActionsOutputs({
|
|
201
|
+
'has-errors': 'false',
|
|
202
|
+
'total-errors': '0',
|
|
203
|
+
'total-warnings': '0',
|
|
204
|
+
'total-valid': '0',
|
|
205
|
+
'file-count': '0',
|
|
206
|
+
});
|
|
192
207
|
return;
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
const { totalErrors, totalWarnings, totalValid, fileCount } = result;
|
|
196
211
|
|
|
212
|
+
writeGithubActionsOutputs({
|
|
213
|
+
'has-errors': String(totalErrors > 0),
|
|
214
|
+
'total-errors': String(totalErrors),
|
|
215
|
+
'total-warnings': String(totalWarnings),
|
|
216
|
+
'total-valid': String(totalValid),
|
|
217
|
+
'file-count': String(fileCount),
|
|
218
|
+
});
|
|
219
|
+
|
|
197
220
|
console.log();
|
|
198
221
|
if (totalErrors > 0) {
|
|
199
222
|
const parts = [`${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'}`];
|
package/src/gha-pre.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Pre step for the readmeio/cli GitHub Action (see action.yml's runs.pre).
|
|
2
|
+
//
|
|
3
|
+
// dist-gha/index.js (built by the build:gha script) excludes @readme/markdown
|
|
4
|
+
// from its bundle: it's a ~25MB package with 50+ dependencies (including
|
|
5
|
+
// tailwindcss and postcss) pulled in for one narrow helper the "lint" command
|
|
6
|
+
// uses, mdxishTags, and bundling it whole breaks on its embedded CSS/asset
|
|
7
|
+
// references. Rather than vendoring that whole tree into this repo, this step
|
|
8
|
+
// installs just that one package into the action's own directory immediately
|
|
9
|
+
// before dist-gha/index.js runs, so plain Node module resolution finds it.
|
|
10
|
+
//
|
|
11
|
+
// GITHUB_ACTION_PATH looked like the natural way to find that directory (and
|
|
12
|
+
// is what a version of this file first tried), but it came back undefined
|
|
13
|
+
// when actually tested on a real Actions runner. import.meta.dirname doesn't
|
|
14
|
+
// depend on the runner setting anything at all -- it's just "where is this
|
|
15
|
+
// file" -- and it's the same approach readmeio/rdme's own pre-step
|
|
16
|
+
// (bin/write-gha-pjson.js) already uses.
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const actionPath = path.resolve(import.meta.dirname, '..');
|
|
22
|
+
const pkg = JSON.parse(readFileSync(path.join(actionPath, 'package.json'), 'utf-8'));
|
|
23
|
+
const range = pkg.dependencies['@readme/markdown'];
|
|
24
|
+
|
|
25
|
+
execFileSync(
|
|
26
|
+
'npm',
|
|
27
|
+
['install', '--no-save', '--no-audit', '--no-fund', '--prefix', actionPath, `@readme/markdown@${range}`],
|
|
28
|
+
{ stdio: 'inherit' },
|
|
29
|
+
);
|
package/src/gha.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Entrypoint for the readmeio/cli GitHub Action. Deliberately does NOT go
|
|
2
|
+
// through src/cli.js — that bootstraps the full CLI by scanning src/commands/
|
|
3
|
+
// on disk at runtime (fs.readdirSync + dynamic import), which a bundler can't
|
|
4
|
+
// follow statically. This file instead imports just the three commands the
|
|
5
|
+
// Action exposes directly, so `ncc build` can produce one dependency-free
|
|
6
|
+
// file with no runtime install step (see package.json's build:gha script).
|
|
7
|
+
//
|
|
8
|
+
// Takes one input, `readme` (env var INPUT_README per GitHub Actions'
|
|
9
|
+
// convention), a plain command string such as "lint" or
|
|
10
|
+
// "oas:validate --dereference". Split into a command name and flags; there's
|
|
11
|
+
// no need for a full argument parser since each of these three commands only
|
|
12
|
+
// ever takes simple boolean flags.
|
|
13
|
+
//
|
|
14
|
+
// Each command's own run() already writes its structured results (has-errors,
|
|
15
|
+
// skipped-count, etc.) to GITHUB_OUTPUT itself, via utils/gha-output.js's
|
|
16
|
+
// GITHUB_ACTIONS-detecting helper — so this file just needs to call run() and
|
|
17
|
+
// let it do what it already does, exit code included.
|
|
18
|
+
|
|
19
|
+
import * as lint from './commands/lint.js';
|
|
20
|
+
import * as oasValidate from './commands/oas-validate.js';
|
|
21
|
+
import * as oasSync from './commands/oas-sync.js';
|
|
22
|
+
import { writeGithubActionsOutputs } from './utils/gha-output.js';
|
|
23
|
+
|
|
24
|
+
// Each command's run(options, cmd, ctx) only ever reads specific boolean
|
|
25
|
+
// flags off `options` (confirmed by reading all three) and never uses `cmd`
|
|
26
|
+
// (the commander Command instance normal CLI invocation would pass) — so a
|
|
27
|
+
// plain options object and a null cmd are enough here.
|
|
28
|
+
const COMMANDS = {
|
|
29
|
+
'lint': { run: lint.run, flags: ['json', 'github', 'fix'] },
|
|
30
|
+
'validate': { run: lint.run, flags: ['json', 'github', 'fix'] }, // alias, matches src/commands/lint.js
|
|
31
|
+
'oas:validate': { run: oasValidate.run, flags: ['dereference'] },
|
|
32
|
+
'oas:sync': { run: oasSync.run, flags: [] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function parseInput(input) {
|
|
36
|
+
const tokens = input.trim().split(/\s+/).filter(Boolean);
|
|
37
|
+
const [command, ...rest] = tokens;
|
|
38
|
+
return { command, rest };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function run() {
|
|
42
|
+
const input = process.env.INPUT_README || '';
|
|
43
|
+
const { command, rest } = parseInput(input);
|
|
44
|
+
|
|
45
|
+
const entry = COMMANDS[command];
|
|
46
|
+
if (!entry) {
|
|
47
|
+
console.error(
|
|
48
|
+
`::error::Unknown or unsupported command "${command || '(empty)'}" for the \`readme\` input. ` +
|
|
49
|
+
`Supported: ${[...new Set(Object.keys(COMMANDS).filter((k) => k !== 'validate'))].join(', ')}.`,
|
|
50
|
+
);
|
|
51
|
+
writeGithubActionsOutputs({ 'has-errors': 'true' });
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every trailing token must be exactly "--<a supported flag>" — anything
|
|
56
|
+
// else (a short option like "-d", a malformed token, a misspelled flag)
|
|
57
|
+
// is rejected rather than silently dropped. An earlier version of this
|
|
58
|
+
// only checked tokens that already happened to start with "--", which let
|
|
59
|
+
// anything shaped differently (like "-d") skip validation entirely and
|
|
60
|
+
// run the command with default options with no indication anything was
|
|
61
|
+
// wrong.
|
|
62
|
+
const flags = new Set();
|
|
63
|
+
const badTokens = [];
|
|
64
|
+
for (const token of rest) {
|
|
65
|
+
const flag = token.startsWith('--') ? token.slice(2) : null;
|
|
66
|
+
if (flag && entry.flags.includes(flag)) {
|
|
67
|
+
flags.add(flag);
|
|
68
|
+
} else {
|
|
69
|
+
badTokens.push(token);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (badTokens.length > 0) {
|
|
74
|
+
console.error(
|
|
75
|
+
`::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map((t) => `"${t}"`).join(', ')} for "${command}". ` +
|
|
76
|
+
`Supported: ${entry.flags.length > 0 ? entry.flags.map((f) => `--${f}`).join(', ') : '(none)'}.`,
|
|
77
|
+
);
|
|
78
|
+
writeGithubActionsOutputs({ 'has-errors': 'true' });
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const options = {};
|
|
83
|
+
for (const flag of entry.flags) options[flag] = flags.has(flag);
|
|
84
|
+
|
|
85
|
+
// The customer's repo, checked out by their own workflow before this
|
|
86
|
+
// action runs — not this action's own (separately checked-out) source.
|
|
87
|
+
const gitRoot = process.env.GITHUB_WORKSPACE || process.cwd();
|
|
88
|
+
|
|
89
|
+
await entry.run(options, null, { gitRoot });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
run().catch((err) => {
|
|
93
|
+
console.error(err);
|
|
94
|
+
// A thrown error (as opposed to a command's own controlled process.exit)
|
|
95
|
+
// means whatever run() was doing never reached its own
|
|
96
|
+
// writeGithubActionsOutputs call, so this is the last chance to publish
|
|
97
|
+
// has-errors — without it, a downstream `if: always()` step would see an
|
|
98
|
+
// empty string, on exactly the failure path where that check matters
|
|
99
|
+
// most. Command-specific fields (skipped-count, results, etc.) aren't
|
|
100
|
+
// meaningful here since they were never computed, so has-errors is all
|
|
101
|
+
// this can honestly guarantee.
|
|
102
|
+
writeGithubActionsOutputs({ 'has-errors': 'true' });
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes each field as a GitHub Actions step output, so a workflow can read
|
|
6
|
+
* e.g. `steps.sync.outputs.skipped-count` instead of scraping printed text.
|
|
7
|
+
* A no-op anywhere else (a plain local CLI run, or CI systems other than
|
|
8
|
+
* GitHub Actions), detected the same way GitHub's own `@actions/core`
|
|
9
|
+
* toolkit does: the `GITHUB_ACTIONS` env var GitHub sets on every runner,
|
|
10
|
+
* plus the `GITHUB_OUTPUT` file path it provides for the current step.
|
|
11
|
+
*
|
|
12
|
+
* @param {Record<string, string | object | Array<unknown>>} fields Values are
|
|
13
|
+
* written as-is if already a string, JSON-stringified otherwise.
|
|
14
|
+
*/
|
|
15
|
+
export function writeGithubActionsOutputs(fields) {
|
|
16
|
+
if (process.env.GITHUB_ACTIONS !== 'true') return;
|
|
17
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
18
|
+
if (!outputPath) return;
|
|
19
|
+
|
|
20
|
+
const lines = Object.entries(fields).map(([name, value]) => {
|
|
21
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
22
|
+
// A random delimiter, rather than a fixed one like "EOF", matters here:
|
|
23
|
+
// `value` can echo back content from a customer's own docs/OAS files
|
|
24
|
+
// (an error message quoting their spec, say), and a guessable delimiter
|
|
25
|
+
// could let that content prematurely close the heredoc and inject extra
|
|
26
|
+
// key=value pairs into the outputs file.
|
|
27
|
+
const delimiter = `ghadelimiter_${randomUUID()}`;
|
|
28
|
+
return `${name}<<${delimiter}\n${text}\n${delimiter}`;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
fs.appendFileSync(outputPath, `${lines.join('\n')}\n`);
|
|
32
|
+
}
|
package/src/utils/lint.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import
|
|
3
|
+
import * as componentsValidator from '../validators/components.js'
|
|
4
|
+
import * as contentValidator from '../validators/content.js'
|
|
5
|
+
import * as duplicatesValidator from '../validators/duplicates.js'
|
|
6
|
+
import * as frontmatterValidator from '../validators/frontmatter.js'
|
|
7
|
+
import * as linksValidator from '../validators/links.js'
|
|
8
|
+
import * as nestingValidator from '../validators/nesting.js'
|
|
9
|
+
import * as oasReferenceValidator from '../validators/oas-reference.js'
|
|
10
|
+
import * as oasSchemaValidator from '../validators/oas-schema.js'
|
|
11
|
+
import * as orderingValidator from '../validators/ordering.js'
|
|
12
|
+
import * as recipesValidator from '../validators/recipes.js'
|
|
4
13
|
|
|
5
14
|
const TARGET_PATTERNS = [
|
|
6
15
|
{ dir: 'custom_blocks', ext: ['.mdx', '.md'] },
|
|
@@ -32,24 +41,30 @@ export function collectFiles(gitRoot) {
|
|
|
32
41
|
return files.sort()
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
// Every validator in src/validators/, imported statically (rather than
|
|
45
|
+
// discovered via a runtime directory scan) so this file — and anything that
|
|
46
|
+
// imports it, like the GitHub Action entrypoint in src/gha.js — can be
|
|
47
|
+
// bundled into a single file by a tool like ncc. Add a new validator's
|
|
48
|
+
// import above and its module here.
|
|
49
|
+
const ALL_VALIDATORS = [
|
|
50
|
+
componentsValidator,
|
|
51
|
+
contentValidator,
|
|
52
|
+
duplicatesValidator,
|
|
53
|
+
frontmatterValidator,
|
|
54
|
+
linksValidator,
|
|
55
|
+
nestingValidator,
|
|
56
|
+
oasReferenceValidator,
|
|
57
|
+
oasSchemaValidator,
|
|
58
|
+
orderingValidator,
|
|
59
|
+
recipesValidator,
|
|
60
|
+
]
|
|
61
|
+
|
|
35
62
|
/**
|
|
36
|
-
* Auto-discover all validators from src/validators/.
|
|
37
63
|
* Validators can export `validate()` for per-file checks and/or
|
|
38
64
|
* `validateAll()` for cross-file checks (like ordering).
|
|
39
65
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const files = fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js'))
|
|
43
|
-
const validators = []
|
|
44
|
-
|
|
45
|
-
for (const file of files) {
|
|
46
|
-
const mod = await import(pathToFileURL(path.join(validatorsDir, file)).href)
|
|
47
|
-
if (mod.name && (mod.validate || mod.validateAll)) {
|
|
48
|
-
validators.push(mod)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return validators
|
|
66
|
+
function loadValidators() {
|
|
67
|
+
return ALL_VALIDATORS.filter((mod) => mod.name && (mod.validate || mod.validateAll))
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/**
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
findOasFiles,
|
|
6
|
+
extractOperations,
|
|
7
|
+
collectExistingPages,
|
|
8
|
+
syncOas,
|
|
9
|
+
operationKey,
|
|
10
|
+
findIgnoredInternalExtensions,
|
|
11
|
+
} from '../commands/oas-sync.js';
|
|
5
12
|
|
|
6
13
|
export const name = 'oas-reference';
|
|
7
14
|
|
|
@@ -16,6 +23,17 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
16
23
|
const oasMap = new Map();
|
|
17
24
|
for (const { filename, spec } of oasFiles) {
|
|
18
25
|
oasMap.set(filename, { spec, ops: extractOperations(spec) });
|
|
26
|
+
|
|
27
|
+
// Check: `x-readme.internal`, which ReadMe ignores for page visibility.
|
|
28
|
+
for (const location of findIgnoredInternalExtensions(spec)) {
|
|
29
|
+
results.push({
|
|
30
|
+
file: `reference/${filename}`,
|
|
31
|
+
rule: name,
|
|
32
|
+
severity: 'warning',
|
|
33
|
+
message: `"x-readme.internal" is ignored by ReadMe (${location}); use "x-internal" instead to hide pages`,
|
|
34
|
+
fixable: false,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
19
37
|
}
|
|
20
38
|
|
|
21
39
|
// Collect all reference pages with api frontmatter.
|
|
@@ -87,8 +105,11 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
87
105
|
}
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
// Apply fixes by running the full sync
|
|
91
|
-
|
|
108
|
+
// Apply fixes by running the full sync — but only when something reported
|
|
109
|
+
// is actually fixable. The sync adds, deletes, moves, hides and reorders
|
|
110
|
+
// reference files, which is far too much to do on the strength of an
|
|
111
|
+
// unfixable warning (`x-readme.internal`, a page pointing at a missing spec).
|
|
112
|
+
if (fix && results.some((r) => r.fixable)) {
|
|
92
113
|
const syncResults = syncOas(gitRoot);
|
|
93
114
|
if (syncResults) {
|
|
94
115
|
for (const r of results) {
|