@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.
@@ -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 { pathToFileURL, fileURLToPath } from 'node:url'
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
- async function loadValidators() {
41
- const validatorsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'validators')
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 { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js';
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
- if (fix && results.length > 0) {
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) {