@readme/cli 0.0.29 → 0.0.31
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 +27 -0
- package/package.json +8 -4
- package/src/cli.js +2 -2
- package/src/commands/import-oas.test.js +277 -0
- package/src/commands/import.classify.test.js +172 -0
- package/src/commands/import.js +793 -92
- package/src/commands/import.landing.test.js +217 -0
- package/src/commands/import.staging.test.js +158 -0
- package/src/commands/import.test.js +246 -0
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-sync.js +797 -59
- 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/utils/llms.test.js +45 -0
- package/src/utils/reporter.js +2 -1
- package/src/utils/slug-for-entry.js +16 -0
- package/src/validators/components.js +10 -7
- package/src/validators/duplicates.js +2 -4
- package/src/validators/links.js +2 -8
- package/src/validators/oas-reference.js +35 -9
|
@@ -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
|
/**
|
package/src/utils/llms.test.js
CHANGED
|
@@ -140,6 +140,51 @@ test('parseLlmsTxt: items with no H2 fall into implicit Resources section', () =
|
|
|
140
140
|
assert.equal(result.sections[0].title, 'Resources')
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
+
test('parseLlmsTxt: strips trailing (section) markers from H2 titles', () => {
|
|
144
|
+
const body = [
|
|
145
|
+
'# My Docs',
|
|
146
|
+
'',
|
|
147
|
+
'## getting-started (section)',
|
|
148
|
+
'- [Overview](https://docs.example.com/overview)',
|
|
149
|
+
'',
|
|
150
|
+
'## API Reference (SECTION)',
|
|
151
|
+
'- [Pets](https://docs.example.com/pets)',
|
|
152
|
+
'',
|
|
153
|
+
'## Guides (section) extra',
|
|
154
|
+
'- [Install](https://docs.example.com/install)',
|
|
155
|
+
].join('\n')
|
|
156
|
+
|
|
157
|
+
const result = parseLlmsTxt(body, BASE)
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
result.sections.map((s) => s.title),
|
|
160
|
+
['getting-started', 'API Reference', 'Guides (section) extra'],
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('parseLlmsTxt: strips (section) markers when falling back to H3 headings', () => {
|
|
165
|
+
const manyItems = Array.from({ length: 201 }, (_, i) => `- [Page ${i}](https://docs.example.com/page-${i})`).join('\n')
|
|
166
|
+
const body = [
|
|
167
|
+
'# Big Site',
|
|
168
|
+
'',
|
|
169
|
+
'## Docs',
|
|
170
|
+
manyItems,
|
|
171
|
+
'',
|
|
172
|
+
'### getting-started (section)',
|
|
173
|
+
'- [A1](https://docs.example.com/a1)',
|
|
174
|
+
'',
|
|
175
|
+
'### Reference (section)',
|
|
176
|
+
'- [B1](https://docs.example.com/b1)',
|
|
177
|
+
].join('\n')
|
|
178
|
+
|
|
179
|
+
const result = parseLlmsTxt(body, BASE)
|
|
180
|
+
assert.equal(result.h3Fallback, true)
|
|
181
|
+
const titles = result.sections.map((s) => s.title)
|
|
182
|
+
assert.ok(titles.includes('getting-started'))
|
|
183
|
+
assert.ok(titles.includes('Reference'))
|
|
184
|
+
assert.ok(!titles.includes('getting-started (section)'))
|
|
185
|
+
assert.ok(!titles.includes('Reference (section)'))
|
|
186
|
+
})
|
|
187
|
+
|
|
143
188
|
// ---------------------------------------------------------------------------
|
|
144
189
|
// parseLlmsTxt — H3 fallback
|
|
145
190
|
// ---------------------------------------------------------------------------
|
package/src/utils/reporter.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import ora from "ora";
|
|
6
7
|
import * as styles from "./styles.js";
|
|
7
8
|
import { hasClaude } from "./claude.js";
|
|
@@ -177,7 +178,7 @@ export function createHumanReporter() {
|
|
|
177
178
|
// When running inside Claude, output instructions and structured issue list.
|
|
178
179
|
if (isRunningInClaude && unfixed.length > 0) {
|
|
179
180
|
const cliRoot = path.join(
|
|
180
|
-
path.dirname(
|
|
181
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
181
182
|
"../..",
|
|
182
183
|
);
|
|
183
184
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical slug for a git-format page path. `index.md`/`index.mdx` carries
|
|
5
|
+
* its parent folder's slug (e.g. `docs/a/setup/index.md` → "setup", matching
|
|
6
|
+
* how gitto slugs a folder from its index page); any other file is its
|
|
7
|
+
* filename minus the extension. A root-level index file falls back to "index".
|
|
8
|
+
*/
|
|
9
|
+
export function slugForEntry(relPath) {
|
|
10
|
+
const filename = path.basename(relPath);
|
|
11
|
+
if (filename === 'index.md' || filename === 'index.mdx') {
|
|
12
|
+
const parentDir = path.basename(path.dirname(relPath));
|
|
13
|
+
return parentDir && parentDir !== '.' && parentDir !== path.sep ? parentDir : 'index';
|
|
14
|
+
}
|
|
15
|
+
return filename.replace(/\.(md|mdx)$/, '');
|
|
16
|
+
}
|
|
@@ -135,19 +135,22 @@ export function validate({ content, relativePath }) {
|
|
|
135
135
|
|
|
136
136
|
const results = [];
|
|
137
137
|
|
|
138
|
-
// .md snippet files: filename
|
|
138
|
+
// .md snippet files: the filename becomes a component tag <Name />, so it must
|
|
139
|
+
// be a valid JS identifier starting with an uppercase letter. Underscores and
|
|
140
|
+
// digits are fine (<Helm_v4_error /> is valid); hyphens and a lowercase initial
|
|
141
|
+
// are not.
|
|
139
142
|
if (isMd) {
|
|
140
143
|
const slug = filename.replace(/\.md$/, "");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(slug)) {
|
|
145
|
+
const suggestion = slug
|
|
146
|
+
.split(/[-_]/)
|
|
147
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
148
|
+
.join("");
|
|
146
149
|
results.push({
|
|
147
150
|
file: relativePath,
|
|
148
151
|
rule: name,
|
|
149
152
|
severity: "warning",
|
|
150
|
-
message: `Bad filename:
|
|
153
|
+
message: `Bad filename: "${slug}" can't be used as a component tag — rename to "${suggestion}.md" so it can be used as <${suggestion} />`,
|
|
151
154
|
});
|
|
152
155
|
}
|
|
153
156
|
return results.length > 0 ? results : null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { slugForEntry } from '../utils/slug-for-entry.js';
|
|
2
3
|
|
|
3
4
|
export const name = 'duplicates';
|
|
4
5
|
|
|
@@ -14,10 +15,7 @@ export function validateAll(files) {
|
|
|
14
15
|
const topDir = relPath.split('/')[0];
|
|
15
16
|
if (!CHECKED_DIRS.includes(topDir)) continue;
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
-
if (filename === 'index.md' || filename === 'index.mdx') continue;
|
|
19
|
-
|
|
20
|
-
const slug = filename.replace(/\.(md|mdx)$/, '');
|
|
18
|
+
const slug = slugForEntry(relPath);
|
|
21
19
|
const key = `${topDir}:${slug}`;
|
|
22
20
|
if (!slugMap.has(key)) slugMap.set(key, []);
|
|
23
21
|
slugMap.get(key).push(relPath);
|
package/src/validators/links.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
|
+
import { slugForEntry } from '../utils/slug-for-entry.js';
|
|
4
5
|
|
|
5
6
|
export const name = 'links';
|
|
6
7
|
|
|
@@ -19,14 +20,7 @@ function collectSlugs(gitRoot) {
|
|
|
19
20
|
const entries = fs.readdirSync(dirPath, { recursive: true });
|
|
20
21
|
for (const entry of entries) {
|
|
21
22
|
if (!/\.(md|mdx)$/.test(entry)) continue;
|
|
22
|
-
|
|
23
|
-
if (filename === 'index.md' || filename === 'index.mdx') {
|
|
24
|
-
// index.md represents the parent directory, e.g. plans-and-pricing/index.md -> slug "plans-and-pricing"
|
|
25
|
-
const parentDir = path.basename(path.dirname(entry));
|
|
26
|
-
if (parentDir && parentDir !== '.') slugs.add(parentDir);
|
|
27
|
-
} else {
|
|
28
|
-
slugs.add(filename.replace(/\.(md|mdx)$/, ''));
|
|
29
|
-
}
|
|
23
|
+
slugs.add(slugForEntry(entry));
|
|
30
24
|
}
|
|
31
25
|
}
|
|
32
26
|
|
|
@@ -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.
|
|
@@ -34,6 +52,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
34
52
|
|
|
35
53
|
const oasFilename = data.api.file;
|
|
36
54
|
const operationId = data.api.operationId;
|
|
55
|
+
const isWebhook = !!data.api.webhook;
|
|
37
56
|
const oas = oasMap.get(oasFilename);
|
|
38
57
|
|
|
39
58
|
// Check: OAS file doesn't exist.
|
|
@@ -50,7 +69,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
50
69
|
if (!operationId) continue;
|
|
51
70
|
|
|
52
71
|
// Check: operationId doesn't exist in the spec.
|
|
53
|
-
if (!oas.ops.has(operationId)) {
|
|
72
|
+
if (!oas.ops.has(operationKey({ operationId, isWebhook }))) {
|
|
54
73
|
results.push({
|
|
55
74
|
file: relPath,
|
|
56
75
|
rule: name,
|
|
@@ -67,23 +86,30 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
67
86
|
const existingPages = collectExistingPages(refDir);
|
|
68
87
|
for (const [oasFilename, { ops }] of oasMap) {
|
|
69
88
|
const pagesForOas = existingPages.filter((p) => p.data.api.file === oasFilename);
|
|
70
|
-
const coveredOps = new Set(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
const coveredOps = new Set(
|
|
90
|
+
pagesForOas.map((p) =>
|
|
91
|
+
operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook }),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
for (const op of ops.values()) {
|
|
96
|
+
if (!coveredOps.has(operationKey(op))) {
|
|
74
97
|
results.push({
|
|
75
98
|
file: `reference/${oasFilename}`,
|
|
76
99
|
rule: name,
|
|
77
100
|
severity: 'warning',
|
|
78
|
-
message: `Missing page: no reference page found for operation "${
|
|
101
|
+
message: `Missing page: no reference page found for operation "${op.operationId}"`,
|
|
79
102
|
fixable: true,
|
|
80
103
|
});
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
}
|
|
84
107
|
|
|
85
|
-
// Apply fixes by running the full sync
|
|
86
|
-
|
|
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)) {
|
|
87
113
|
const syncResults = syncOas(gitRoot);
|
|
88
114
|
if (syncResults) {
|
|
89
115
|
for (const r of results) {
|