@readme/cli 0.0.30 → 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 +6 -2
- package/src/commands/lint.js +4 -4
- 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
package/README.md
CHANGED
|
@@ -50,6 +50,33 @@ Starts a local dev server so you can preview your docs as you write them. Watche
|
|
|
50
50
|
npx @readme/cli dev
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
## GitHub Action
|
|
54
|
+
|
|
55
|
+
This repo also publishes itself as a GitHub Action, so a workflow can run `lint`, `oas:validate`, or `oas:sync` directly, pinned to a version, without an `npx` install step on every run:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
- uses: readmeio/cli@v1
|
|
59
|
+
with:
|
|
60
|
+
readme: oas:sync
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `readme` input is the same command you'd run on the CLI, e.g. `lint`, `oas:validate --dereference`, or `oas:sync`. Each command exits non-zero on a real problem, same as running it locally, and publishes its results as step outputs — `has-errors`, `skipped-count`, `total-errors`, and more (see [`action.yml`](./action.yml) for the full list) — so a later step can check them directly instead of parsing log output:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- name: Sync reference pages from OpenAPI specs
|
|
67
|
+
id: sync
|
|
68
|
+
uses: readmeio/cli@v1
|
|
69
|
+
with:
|
|
70
|
+
readme: oas:sync
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- if: steps.sync.outputs.skipped-count != '0'
|
|
75
|
+
run: echo "::error::oas:sync skipped a page — see the sync step above."
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Note: the first run of a workflow using this Action installs one extra dependency (`@readme/markdown`) behind the scenes before your command runs — this is expected, and only adds a few seconds.
|
|
79
|
+
|
|
53
80
|
## Feedback
|
|
54
81
|
|
|
55
82
|
If something doesn't work right, or if you have suggestions, please [open an issue](https://github.com/readmeio/cli/issues). We're actively building this and want to hear from you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readme/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"description": "The ReadMe CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node bin/readme.js",
|
|
28
|
-
"test": "node --test src/**/*.test.js test/*.test.js"
|
|
28
|
+
"test": "node --test src/**/*.test.js test/*.test.js",
|
|
29
|
+
"build:gha": "ncc build src/gha.js -o dist-gha --external @readme/markdown"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=22"
|
|
@@ -56,5 +57,8 @@
|
|
|
56
57
|
"react": "^18.3.1",
|
|
57
58
|
"react-dom": "^18.3.1",
|
|
58
59
|
"update-notifier": "^7.3.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@vercel/ncc": "^0.45.0"
|
|
59
63
|
}
|
|
60
64
|
}
|
package/src/commands/lint.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { createRequire } from 'node:module'
|
|
2
1
|
import { collectFiles, runValidators } from '../utils/lint.js'
|
|
3
2
|
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
|
|
4
3
|
import { printHeader, isAgenticCli } from '../utils/eyes.js'
|
|
5
4
|
import * as styles from '../utils/styles.js'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const pkg = require('../../package.json')
|
|
5
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js'
|
|
6
|
+
import pkg from '../../package.json' with { type: 'json' }
|
|
9
7
|
|
|
10
8
|
export const command = 'lint'
|
|
11
9
|
export const order = 1
|
|
@@ -59,6 +57,8 @@ export async function run(options, _cmd, ctx) {
|
|
|
59
57
|
|
|
60
58
|
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
|
|
61
59
|
|
|
60
|
+
writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
|
|
61
|
+
|
|
62
62
|
if (hasErrors) {
|
|
63
63
|
// Wait for stdout to flush (important when piped to a file), then exit
|
|
64
64
|
await new Promise((resolve) => process.stdout.write('', resolve))
|
package/src/commands/oas-sync.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import * as styles from '../utils/styles.js';
|
|
6
|
+
import { writeGithubActionsOutputs } from '../utils/gha-output.js';
|
|
6
7
|
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const yaml = require('js-yaml');
|
|
@@ -115,6 +116,60 @@ function resolveLocalPathItemRef(entry, spec) {
|
|
|
115
116
|
return finish();
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the `x-internal` extension for an operation the way the platform
|
|
121
|
+
* does on OAS upload: an operation-level value wins, falling back to the
|
|
122
|
+
* spec root. `present` is false when neither sets it, in which case the
|
|
123
|
+
* page's visibility is left to whoever owns it (new pages default to
|
|
124
|
+
* visible, existing pages keep whatever `hidden` they already have).
|
|
125
|
+
* `x-readme: { internal: true }` is deliberately not read — the platform's
|
|
126
|
+
* page sync only honors the bare `x-internal` key.
|
|
127
|
+
*/
|
|
128
|
+
function resolveXInternal(operation, spec) {
|
|
129
|
+
if (operation && 'x-internal' in operation) return { present: true, value: operation['x-internal'] };
|
|
130
|
+
if (spec && 'x-internal' in spec) return { present: true, value: spec['x-internal'] };
|
|
131
|
+
return { present: false, value: undefined };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find every `x-readme: { internal: ... }` in a spec. The `oas` package
|
|
136
|
+
* documents it as an alternative spelling of `x-internal`, but the platform's
|
|
137
|
+
* page sync never reads it (see `resolveXInternal`), so it silently has no
|
|
138
|
+
* effect. Returns human-readable locations (`root`, `GET /pets`,
|
|
139
|
+
* `webhook POST newPet`) for lint to warn about.
|
|
140
|
+
*/
|
|
141
|
+
export function findIgnoredInternalExtensions(spec) {
|
|
142
|
+
const hasInternal = (obj) => {
|
|
143
|
+
const xReadme = obj?.['x-readme'];
|
|
144
|
+
return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const locations = [];
|
|
148
|
+
if (hasInternal(spec)) locations.push('root');
|
|
149
|
+
|
|
150
|
+
for (const [entries, isWebhook] of [[spec?.paths, false], [spec?.webhooks, true]]) {
|
|
151
|
+
for (const [name, rawItem] of Object.entries(entries || {})) {
|
|
152
|
+
for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) {
|
|
153
|
+
if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue;
|
|
154
|
+
locations.push(`${isWebhook ? 'webhook ' : ''}${method.toUpperCase()} ${name}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return locations;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Read a root-level ReadMe extension, in the same precedence as the `oas`
|
|
163
|
+
* package's `getExtension()` with no operation: `x-readme.<name>`, then
|
|
164
|
+
* `x-<name>`, then a bare `<name>`.
|
|
165
|
+
*/
|
|
166
|
+
function getRootExtension(spec, name) {
|
|
167
|
+
const xReadme = spec?.['x-readme'];
|
|
168
|
+
if (xReadme && typeof xReadme === 'object' && name in xReadme) return xReadme[name];
|
|
169
|
+
if (spec && `x-${name}` in spec) return spec[`x-${name}`];
|
|
170
|
+
return spec?.[name];
|
|
171
|
+
}
|
|
172
|
+
|
|
118
173
|
/**
|
|
119
174
|
* Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks`
|
|
120
175
|
* (callouts the API itself makes to a client-registered URL, not endpoints the
|
|
@@ -143,9 +198,11 @@ export function extractOperations(spec) {
|
|
|
143
198
|
operationId,
|
|
144
199
|
summary: operation.summary || null,
|
|
145
200
|
description: operation.description || null,
|
|
146
|
-
|
|
201
|
+
// The platform groups by the first *non-empty* tag.
|
|
202
|
+
tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null,
|
|
147
203
|
path: pathStr,
|
|
148
204
|
isWebhook,
|
|
205
|
+
xInternal: resolveXInternal(operation, spec),
|
|
149
206
|
});
|
|
150
207
|
}
|
|
151
208
|
}
|
|
@@ -264,7 +321,7 @@ function stringifyFrontmatter(frontmatter) {
|
|
|
264
321
|
return matter.stringify('', frontmatter).replace(/\n+$/, '');
|
|
265
322
|
}
|
|
266
323
|
|
|
267
|
-
function buildPageContent({ oasFilename, operationId, isWebhook }) {
|
|
324
|
+
function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
|
|
268
325
|
const frontmatter = {
|
|
269
326
|
api: {
|
|
270
327
|
file: oasFilename,
|
|
@@ -274,19 +331,12 @@ function buildPageContent({ oasFilename, operationId, isWebhook }) {
|
|
|
274
331
|
// what the platform stamps on a page generated from `webhooks`.
|
|
275
332
|
...(isWebhook ? { webhook: true } : {}),
|
|
276
333
|
},
|
|
277
|
-
//
|
|
278
|
-
// always written
|
|
279
|
-
// `
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
// match gitto#2095 (RM-4616 / CX-3303): resolve `hidden` from operation-level
|
|
284
|
-
// `x-internal`, falling back to root-level, else false; and hide a tag's
|
|
285
|
-
// index page when all of its operations are `x-internal: true`. Deferred to
|
|
286
|
-
// keep oas:sync create-only — the resync-side rules (re-applying x-internal
|
|
287
|
-
// to existing pages, parent hide-ratchet) would require mutating existing
|
|
288
|
-
// pages, which this command intentionally never does.
|
|
289
|
-
hidden: false,
|
|
334
|
+
// The backend does not infer visibility from a missing field, so it's
|
|
335
|
+
// always written explicitly: `x-internal` when the spec sets it (see
|
|
336
|
+
// `resolveXInternal`), otherwise `false` — mirroring the platform's
|
|
337
|
+
// OAS-upload, which writes a new endpoint visible even when its tag and
|
|
338
|
+
// siblings are hidden.
|
|
339
|
+
hidden,
|
|
290
340
|
};
|
|
291
341
|
|
|
292
342
|
return stringifyFrontmatter(frontmatter);
|
|
@@ -307,6 +357,25 @@ function buildTagIndexContent(title, description) {
|
|
|
307
357
|
return stringifyFrontmatter(frontmatter);
|
|
308
358
|
}
|
|
309
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Kebab-case a folder segment: lowercase, with any run of whitespace or other
|
|
362
|
+
* non-alphanumeric characters (a space, an underscore, ...) collapsed to a
|
|
363
|
+
* single hyphen. Two roles: (1) as an equivalence key, so "Shipping Labels",
|
|
364
|
+
* "shipping labels" and "shipping-labels" are recognized as the same
|
|
365
|
+
* folder no matter which spelling is already on disk (see
|
|
366
|
+
* `existingFoldersBySlug`); and (2) as the spelling used when *creating* a
|
|
367
|
+
* folder that doesn't exist under any spelling yet, matching what a fresh
|
|
368
|
+
* platform OAS-upload would produce. Neither spelling is the one "true"
|
|
369
|
+
* form — this just needs one fixed spelling to create with and to compare
|
|
370
|
+
* against.
|
|
371
|
+
*/
|
|
372
|
+
function slugifyFolder(value) {
|
|
373
|
+
return value
|
|
374
|
+
.toLowerCase()
|
|
375
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
376
|
+
.replace(/^-+|-+$/g, '');
|
|
377
|
+
}
|
|
378
|
+
|
|
310
379
|
/**
|
|
311
380
|
* The category-folder grouping for an operation. A tagged operation groups
|
|
312
381
|
* under its own tag, as before. An untagged operation groups under a folder
|
|
@@ -316,11 +385,35 @@ function buildTagIndexContent(title, description) {
|
|
|
316
385
|
* one "Other" folder.
|
|
317
386
|
*/
|
|
318
387
|
function operationGroup(op) {
|
|
319
|
-
if (op.tag) return { folder: safeSegment(op.tag, 'Other')
|
|
320
|
-
const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation')
|
|
388
|
+
if (op.tag) return { folder: slugifyFolder(safeSegment(op.tag, 'Other')) || 'other', title: op.tag };
|
|
389
|
+
const folder = slugifyFolder(safeSegment(op.path.replace(/[/{}]/g, ''), 'operation')) || 'operation';
|
|
321
390
|
return { folder, title: op.path };
|
|
322
391
|
}
|
|
323
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Map every existing directory directly under `apiDir` to its slugified
|
|
395
|
+
* name (`slug -> actual on-disk name`), with a single directory read. Used
|
|
396
|
+
* to resolve a group's folder: whatever spelling is already on disk
|
|
397
|
+
* (hyphenated, space-separated, hand-authored, ...) is authoritative and
|
|
398
|
+
* must be reused rather than spawning a second, differently-spelled folder
|
|
399
|
+
* next to it. A slug with no entry here has nothing on disk yet, so the
|
|
400
|
+
* caller falls back to creating it under its hyphenated slug — the spelling
|
|
401
|
+
* a fresh platform OAS-upload would produce.
|
|
402
|
+
*/
|
|
403
|
+
function existingFoldersBySlug(apiDir) {
|
|
404
|
+
const bySlug = new Map();
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = fs.readdirSync(apiDir, { withFileTypes: true });
|
|
408
|
+
} catch {
|
|
409
|
+
return bySlug; // apiDir doesn't exist yet — nothing to reuse.
|
|
410
|
+
}
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name);
|
|
413
|
+
}
|
|
414
|
+
return bySlug;
|
|
415
|
+
}
|
|
416
|
+
|
|
324
417
|
/**
|
|
325
418
|
* Collect every slug already used across the entire reference/ tree, as a
|
|
326
419
|
* lowercase-slug -> owner-count map. Reference page slugs share one flat
|
|
@@ -397,6 +490,133 @@ function reserveSlug(takenSlugs, base) {
|
|
|
397
490
|
return chosen;
|
|
398
491
|
}
|
|
399
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Rewrite a page's frontmatter in place, keeping its body. `mutate` receives
|
|
495
|
+
* a copy of the parsed frontmatter and edits it. Returns true if the file
|
|
496
|
+
* changed. A copy matters: gray-matter caches parse results by input string,
|
|
497
|
+
* so mutating the returned `data` would poison later parses of that content.
|
|
498
|
+
*/
|
|
499
|
+
function updateFrontmatter(filePath, mutate) {
|
|
500
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
501
|
+
const parsed = matter(content);
|
|
502
|
+
const data = structuredClone(parsed.data);
|
|
503
|
+
mutate(data);
|
|
504
|
+
const next = parsed.content.trim()
|
|
505
|
+
? matter.stringify(parsed.content, data)
|
|
506
|
+
: stringifyFrontmatter(data);
|
|
507
|
+
if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
|
|
508
|
+
fs.writeFileSync(filePath, next);
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Merge an OAS-derived slug order into an existing `_order.yaml` list, the
|
|
514
|
+
* way the platform does (gitto's `applyOASOrder`): only the slots already
|
|
515
|
+
* held by one of `orderedSlugs` are refilled, left to right, in OAS order;
|
|
516
|
+
* every other entry (hand-authored pages, other APIs) keeps its position.
|
|
517
|
+
* Slugs not yet listed are inserted right after the last refilled slot, or
|
|
518
|
+
* appended when none of them are listed yet.
|
|
519
|
+
*/
|
|
520
|
+
export function applyOASOrder(currentOrder, orderedSlugs) {
|
|
521
|
+
const desired = [...new Set(orderedSlugs)];
|
|
522
|
+
const desiredSet = new Set(desired);
|
|
523
|
+
// A hand-edited _order.yaml can list the same slug more than once. Collapse
|
|
524
|
+
// duplicates up front so each slot corresponds to exactly one distinct slug;
|
|
525
|
+
// otherwise there are more slots than slugs to refill them with, and the
|
|
526
|
+
// surplus slots would be filled with `undefined`.
|
|
527
|
+
const order = [...new Set(currentOrder)];
|
|
528
|
+
if (!order.length) return desired;
|
|
529
|
+
|
|
530
|
+
const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter((i) => i > -1);
|
|
531
|
+
if (!slots.length) return [...order, ...desired];
|
|
532
|
+
|
|
533
|
+
const remaining = [...desired];
|
|
534
|
+
for (const index of slots) order[index] = remaining.shift();
|
|
535
|
+
if (remaining.length) order.splice(slots.at(-1) + 1, 0, ...remaining);
|
|
536
|
+
return order;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const INDEX_FILES = ['index.md', 'index.mdx', 'index.html'];
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Whether a tag folder's category page looks untouched since it was
|
|
543
|
+
* generated (mirrors gitto's `isAutoGeneratedParentPage`): no body, no
|
|
544
|
+
* frontmatter beyond title/hidden/excerpt, a title that slugifies to the
|
|
545
|
+
* folder name (allowing a `-N` uniqueness suffix), and an excerpt, if any,
|
|
546
|
+
* that is text the spec supplies.
|
|
547
|
+
*
|
|
548
|
+
* The excerpt needs care. The platform never writes one, but this CLI stamps
|
|
549
|
+
* the tag's description as `excerpt` on the pages it generates (see
|
|
550
|
+
* `buildTagIndexContent`), so its presence alone can't mean "hand-edited".
|
|
551
|
+
* Nothing records what a page was generated from, and by the time a folder
|
|
552
|
+
* is being cleaned up its tag has often left the spec, so the excerpt can't
|
|
553
|
+
* be checked against "its" tag either. What can be checked is whether the
|
|
554
|
+
* current spec still supplies that exact text under *any* tag
|
|
555
|
+
* (`specDescriptions`): a generated excerpt is always lifted from
|
|
556
|
+
* `tags[].description`, and a retag that renames a tag usually keeps its
|
|
557
|
+
* description, so this recognizes generated pages across the common rename.
|
|
558
|
+
* An excerpt the spec no longer supplies could be a description that left
|
|
559
|
+
* the spec, or a person's edit; with no way to tell them apart, the page is
|
|
560
|
+
* reported as hand-edited and `cleanupTagFolder` flattens it rather than
|
|
561
|
+
* deleting it. A stale page can be removed by hand; a deleted edit is gone.
|
|
562
|
+
*/
|
|
563
|
+
function isGeneratedTagIndex(indexPath, specDescriptions) {
|
|
564
|
+
let parsed;
|
|
565
|
+
try {
|
|
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;
|
|
572
|
+
if (typeof data.title !== 'string' || !('hidden' in data)) return false;
|
|
573
|
+
if (Object.keys(data).some((k) => !['title', 'hidden', 'excerpt'].includes(k))) return false;
|
|
574
|
+
if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false;
|
|
575
|
+
|
|
576
|
+
const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath)));
|
|
577
|
+
const titleSlug = slugifyFolder(data.title);
|
|
578
|
+
return dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* After `apply-tag-changes` moves pages out of a tag folder, clean up the
|
|
583
|
+
* folder if it's now empty (gitto's `cleanupParentDirectory`). A generated
|
|
584
|
+
* category page is deleted along with its folder; a hand-edited one is kept
|
|
585
|
+
* by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the
|
|
586
|
+
* parent `_order.yaml` entry still applies).
|
|
587
|
+
*/
|
|
588
|
+
function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) {
|
|
589
|
+
let entries;
|
|
590
|
+
try {
|
|
591
|
+
entries = fs.readdirSync(dir);
|
|
592
|
+
} catch {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const others = entries.filter((e) => !INDEX_FILES.includes(e) && e !== '_order.yaml');
|
|
596
|
+
if (others.length) return;
|
|
597
|
+
|
|
598
|
+
const parentDir = path.dirname(dir);
|
|
599
|
+
const slug = path.basename(dir);
|
|
600
|
+
const indexFile = entries.find((e) => INDEX_FILES.includes(e));
|
|
601
|
+
|
|
602
|
+
if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
|
|
603
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
604
|
+
removeFromOrder(path.join(parentDir, '_order.yaml'), slug);
|
|
605
|
+
if (indexFile) {
|
|
606
|
+
releaseSlug(takenSlugs, slug);
|
|
607
|
+
changes.deleted.push(path.relative(refDir, path.join(dir, indexFile)));
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const from = path.join(dir, indexFile);
|
|
613
|
+
const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`);
|
|
614
|
+
if (fs.existsSync(to)) return;
|
|
615
|
+
fs.renameSync(from, to);
|
|
616
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
617
|
+
changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) });
|
|
618
|
+
}
|
|
619
|
+
|
|
400
620
|
/**
|
|
401
621
|
* Run the sync for a single OAS file. Returns changes for that file.
|
|
402
622
|
*
|
|
@@ -410,6 +630,15 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
410
630
|
'api',
|
|
411
631
|
);
|
|
412
632
|
|
|
633
|
+
// Hyphen vs. space in a group's folder name is not a meaningful difference
|
|
634
|
+
// — "shipping-labels" and "shipping labels" are the same folder to a
|
|
635
|
+
// human and to the platform, just spelled differently. One directory read
|
|
636
|
+
// up front (existingFoldersBySlug) is enough to resolve every group's
|
|
637
|
+
// folder for this API as an O(1) lookup, rather than walking apiDir again
|
|
638
|
+
// for each distinct tag/group.
|
|
639
|
+
const apiDir = path.join(refDir, infoTitle);
|
|
640
|
+
const foldersBySlug = existingFoldersBySlug(apiDir);
|
|
641
|
+
|
|
413
642
|
const existingPages = collectExistingPages(refDir).filter(
|
|
414
643
|
(p) => p.data.api.file === oasFilename,
|
|
415
644
|
);
|
|
@@ -422,7 +651,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
422
651
|
);
|
|
423
652
|
}
|
|
424
653
|
|
|
425
|
-
const changes = { added: [], deleted: [], skipped: [] };
|
|
654
|
+
const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] };
|
|
655
|
+
|
|
656
|
+
// Root-only opt-ins; only an explicit `true` counts, so a missing or
|
|
657
|
+
// malformed value leaves existing placement and order alone.
|
|
658
|
+
const applyTagChanges = getRootExtension(spec, 'apply-tag-changes') === true;
|
|
659
|
+
const applyEndpointOrder = getRootExtension(spec, 'apply-endpoint-order') === true;
|
|
426
660
|
|
|
427
661
|
// Tag descriptions from the spec's top-level `tags` array, used for the
|
|
428
662
|
// per-tag category landing page (index.md).
|
|
@@ -431,6 +665,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
431
665
|
.filter((t) => t && t.name)
|
|
432
666
|
.map((t) => [t.name, t.description || null]),
|
|
433
667
|
);
|
|
668
|
+
// Every description the spec supplies, regardless of which tag: what an
|
|
669
|
+
// emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`).
|
|
670
|
+
const specDescriptions = new Set([...tagDescriptions.values()].filter(Boolean));
|
|
434
671
|
|
|
435
672
|
// Deletes: pages referencing operations that no longer exist.
|
|
436
673
|
for (const [opId, page] of pagesByOpId) {
|
|
@@ -478,16 +715,20 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
478
715
|
// declared tag.
|
|
479
716
|
const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
|
|
480
717
|
.filter((t) => t && t.name)
|
|
481
|
-
.map((t) => safeSegment(t.name, 'Other')
|
|
718
|
+
.map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
|
|
482
719
|
const orderedFolders = [
|
|
483
720
|
...declaredOrder.filter((folder) => groupsByFolder.has(folder)),
|
|
484
721
|
...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)),
|
|
485
722
|
];
|
|
486
723
|
|
|
724
|
+
const groupDirs = new Map();
|
|
725
|
+
const createdIndexes = new Set();
|
|
487
726
|
for (const folder of orderedFolders) {
|
|
488
727
|
const { title, description } = groupsByFolder.get(folder);
|
|
489
|
-
const
|
|
728
|
+
const actualFolder = foldersBySlug.get(folder) || folder;
|
|
729
|
+
const pageDir = path.join(refDir, infoTitle, actualFolder);
|
|
490
730
|
if (!isWithin(refDir, pageDir)) continue;
|
|
731
|
+
groupDirs.set(folder, pageDir);
|
|
491
732
|
|
|
492
733
|
const indexPath = path.join(pageDir, 'index.md');
|
|
493
734
|
if (!fs.existsSync(indexPath)) {
|
|
@@ -495,15 +736,80 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
495
736
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
496
737
|
fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
|
|
497
738
|
changes.added.push(path.relative(refDir, indexPath));
|
|
739
|
+
createdIndexes.add(indexPath);
|
|
498
740
|
// The category page's slug is the folder name; reserve it so no operation
|
|
499
741
|
// takes it. Only when just-created — an existing index.md was already
|
|
500
742
|
// counted by collectReferenceSlugs's initial disk walk.
|
|
501
|
-
takeSlug(takenSlugs,
|
|
743
|
+
takeSlug(takenSlugs, actualFolder);
|
|
744
|
+
} else if (applyTagChanges) {
|
|
745
|
+
// With `apply-tag-changes`, the spec owns the category page's title and
|
|
746
|
+
// excerpt too (a tag with no description clears the excerpt). Its body
|
|
747
|
+
// and any other frontmatter are the user's and are kept.
|
|
748
|
+
const updated = updateFrontmatter(indexPath, (data) => {
|
|
749
|
+
data.title = title;
|
|
750
|
+
if (description) data.excerpt = description;
|
|
751
|
+
else delete data.excerpt;
|
|
752
|
+
});
|
|
753
|
+
if (updated) changes.updated.push(path.relative(refDir, indexPath));
|
|
502
754
|
}
|
|
503
|
-
addToOrder(path.join(refDir, infoTitle, '_order.yaml'),
|
|
755
|
+
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
|
|
504
756
|
addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
|
|
505
757
|
}
|
|
506
758
|
|
|
759
|
+
// Where each of this spec's operations ends up this run, by operationKey.
|
|
760
|
+
const opPaths = new Map();
|
|
761
|
+
const vacatedDirs = new Set();
|
|
762
|
+
|
|
763
|
+
// Existing pages: by default they stay wherever they are. Two opt-ins can
|
|
764
|
+
// touch them: `x-internal` (visibility) and `apply-tag-changes` (placement).
|
|
765
|
+
for (const [key, op] of specOps) {
|
|
766
|
+
const page = pagesByOpId.get(key);
|
|
767
|
+
if (!page) continue;
|
|
768
|
+
let filePath = page.filePath;
|
|
769
|
+
|
|
770
|
+
// `apply-tag-changes`: a page still inside this API's category follows
|
|
771
|
+
// its tag's folder, even if it was hand-moved or nested elsewhere in the
|
|
772
|
+
// category. A page moved to another category is the user's call and is
|
|
773
|
+
// never touched. Legacy pages literally named index.md are left alone —
|
|
774
|
+
// moving one would turn it into the destination folder's category page.
|
|
775
|
+
const targetDir = groupDirs.get(operationGroup(op).folder);
|
|
776
|
+
if (
|
|
777
|
+
applyTagChanges &&
|
|
778
|
+
targetDir &&
|
|
779
|
+
isWithin(apiDir, filePath) &&
|
|
780
|
+
path.basename(filePath) !== 'index.md' &&
|
|
781
|
+
path.dirname(filePath) !== targetDir
|
|
782
|
+
) {
|
|
783
|
+
const target = path.join(targetDir, path.basename(filePath));
|
|
784
|
+
if (fs.existsSync(target)) {
|
|
785
|
+
changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId });
|
|
786
|
+
} else {
|
|
787
|
+
const fromDir = path.dirname(filePath);
|
|
788
|
+
const slug = path.basename(filePath, '.md');
|
|
789
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
790
|
+
fs.renameSync(filePath, target);
|
|
791
|
+
removeFromOrder(path.join(fromDir, '_order.yaml'), slug);
|
|
792
|
+
addToOrder(path.join(targetDir, '_order.yaml'), slug);
|
|
793
|
+
vacatedDirs.add(fromDir);
|
|
794
|
+
changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) });
|
|
795
|
+
filePath = target;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// `x-internal`, when the spec sets it (operation or root), decides the
|
|
800
|
+
// page's visibility in both directions. When it's absent the page keeps
|
|
801
|
+
// its own `hidden` — removing the extension never unhides a page.
|
|
802
|
+
if (op.xInternal.present) {
|
|
803
|
+
const hidden = Boolean(op.xInternal.value);
|
|
804
|
+
const updated = updateFrontmatter(filePath, (data) => {
|
|
805
|
+
data.hidden = hidden;
|
|
806
|
+
});
|
|
807
|
+
if (updated) changes.updated.push(path.relative(refDir, filePath));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
opPaths.set(key, filePath);
|
|
811
|
+
}
|
|
812
|
+
|
|
507
813
|
// Adds: operation pages with no page yet. Title/excerpt are owned by the OAS
|
|
508
814
|
// spec at render time, so generated pages carry only the api reference. Slugs
|
|
509
815
|
// are lowercased to match the platform's OAS-upload output.
|
|
@@ -511,7 +817,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
511
817
|
if (pagesByOpId.has(key)) continue;
|
|
512
818
|
|
|
513
819
|
const { folder } = operationGroup(op);
|
|
514
|
-
const pageDir = path.join(refDir, infoTitle, folder);
|
|
820
|
+
const pageDir = path.join(refDir, infoTitle, foldersBySlug.get(folder) || folder);
|
|
515
821
|
// Reference slugs share one flat namespace, so uniquify against every slug
|
|
516
822
|
// already in reference/ — a collision (or the reserved `index` slug) gets a
|
|
517
823
|
// numeric suffix rather than being skipped.
|
|
@@ -526,14 +832,82 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
526
832
|
}
|
|
527
833
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
528
834
|
|
|
529
|
-
const content = buildPageContent({
|
|
835
|
+
const content = buildPageContent({
|
|
836
|
+
oasFilename,
|
|
837
|
+
operationId: op.operationId,
|
|
838
|
+
isWebhook: op.isWebhook,
|
|
839
|
+
hidden: op.xInternal.present ? Boolean(op.xInternal.value) : false,
|
|
840
|
+
});
|
|
530
841
|
fs.writeFileSync(pagePath, content);
|
|
531
842
|
|
|
532
843
|
addToOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
533
844
|
|
|
534
845
|
changes.added.push(path.relative(refDir, pagePath));
|
|
846
|
+
opPaths.set(key, pagePath);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// `x-internal` on category pages: a folder whose operations are *all*
|
|
850
|
+
// internal gets its category page hidden too. This only ever hides — a
|
|
851
|
+
// category page is never unhidden by sync, so a manual `hidden: true`
|
|
852
|
+
// survives. Mirroring the platform, a category page created this run
|
|
853
|
+
// counts any truthy `x-internal`; an existing one needs an explicit `true`.
|
|
854
|
+
const childrenByDir = new Map();
|
|
855
|
+
for (const [key, filePath] of opPaths) {
|
|
856
|
+
const dir = path.dirname(filePath);
|
|
857
|
+
if (!childrenByDir.has(dir)) childrenByDir.set(dir, []);
|
|
858
|
+
childrenByDir.get(dir).push(specOps.get(key).xInternal);
|
|
859
|
+
}
|
|
860
|
+
for (const [dir, children] of childrenByDir) {
|
|
861
|
+
const indexPath = path.join(dir, 'index.md');
|
|
862
|
+
if (!fs.existsSync(indexPath)) continue;
|
|
863
|
+
const isNew = createdIndexes.has(indexPath);
|
|
864
|
+
const allHidden = children.every((x) => x.present && (isNew ? Boolean(x.value) : x.value === true));
|
|
865
|
+
if (!allHidden) continue;
|
|
866
|
+
const updated = updateFrontmatter(indexPath, (data) => {
|
|
867
|
+
data.hidden = true;
|
|
868
|
+
});
|
|
869
|
+
if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
|
|
535
870
|
}
|
|
536
871
|
|
|
872
|
+
// Tag folders emptied by `apply-tag-changes` moves. Never the API's own
|
|
873
|
+
// category folder, and never a folder an operation still lives in.
|
|
874
|
+
for (const dir of vacatedDirs) {
|
|
875
|
+
if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue;
|
|
876
|
+
cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// `apply-endpoint-order`: reorder each folder's operation pages to match
|
|
880
|
+
// the order they're declared in the spec (paths, then webhooks). Only this
|
|
881
|
+
// API's pages are reordered, and only among the `_order.yaml` slots they
|
|
882
|
+
// already hold — other entries keep their place. Files never move.
|
|
883
|
+
if (applyEndpointOrder) {
|
|
884
|
+
const orderByDir = new Map();
|
|
885
|
+
for (const key of specOps.keys()) {
|
|
886
|
+
const filePath = opPaths.get(key);
|
|
887
|
+
if (!filePath || !isWithin(apiDir, filePath)) continue;
|
|
888
|
+
// A legacy page named index.md is ordered in its parent, by folder name.
|
|
889
|
+
const isIndex = path.basename(filePath) === 'index.md';
|
|
890
|
+
const dir = isIndex ? path.dirname(path.dirname(filePath)) : path.dirname(filePath);
|
|
891
|
+
const slug = isIndex ? path.basename(path.dirname(filePath)) : path.basename(filePath, '.md');
|
|
892
|
+
if (!orderByDir.has(dir)) orderByDir.set(dir, []);
|
|
893
|
+
orderByDir.get(dir).push(slug);
|
|
894
|
+
}
|
|
895
|
+
for (const [dir, slugs] of orderByDir) {
|
|
896
|
+
const orderPath = path.join(dir, '_order.yaml');
|
|
897
|
+
const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : [];
|
|
898
|
+
const next = applyOASOrder(current, slugs);
|
|
899
|
+
if (next.join('\n') === current.join('\n')) continue;
|
|
900
|
+
writeOrderYaml(orderPath, next);
|
|
901
|
+
changes.updated.push(path.relative(refDir, orderPath));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Two passes can each touch the same tag page in one run (`apply-tag-changes`
|
|
906
|
+
// syncing its title/excerpt, then `x-internal` hiding it once every operation
|
|
907
|
+
// in it is internal). Report each file once so the printed list and the
|
|
908
|
+
// `updated-count` output reflect files, not writes.
|
|
909
|
+
changes.updated = [...new Set(changes.updated)];
|
|
910
|
+
|
|
537
911
|
return changes;
|
|
538
912
|
}
|
|
539
913
|
|
|
@@ -546,7 +920,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
|
546
920
|
*
|
|
547
921
|
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
548
922
|
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
549
|
-
* changes: { added: string[], deleted: string[]
|
|
923
|
+
* changes: { added: string[], deleted: string[], moved: { from, to }[],
|
|
924
|
+
* updated: string[], skipped: { path, operationId }[] } }>}
|
|
550
925
|
* Returns null if there's no reference/ dir or no specs.
|
|
551
926
|
*/
|
|
552
927
|
export function syncOas(input) {
|
|
@@ -571,30 +946,34 @@ export function syncOas(input) {
|
|
|
571
946
|
return allChanges;
|
|
572
947
|
}
|
|
573
948
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
styles.info('No OpenAPI spec files found in reference/.');
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
|
|
949
|
+
/**
|
|
950
|
+
* Print sync results per spec and return aggregate totals (used by the CLI
|
|
951
|
+
* command). Mirrors validateOasFiles in oas-validate.js: printing and
|
|
952
|
+
* summarizing is a presentation concern kept separate from syncOas's pure
|
|
953
|
+
* programmatic API above.
|
|
954
|
+
*
|
|
955
|
+
* @param {ReturnType<typeof syncOas>} results
|
|
956
|
+
* @returns {{ totalAdded: number, totalDeleted: number, totalMoved: number,
|
|
957
|
+
* totalUpdated: number, totalSkipped: number,
|
|
958
|
+
* skipped: Array<{ filename: string, path: string, operationId: string }> }}
|
|
959
|
+
*/
|
|
960
|
+
export function printSyncResults(results) {
|
|
590
961
|
let totalAdded = 0;
|
|
591
962
|
let totalDeleted = 0;
|
|
963
|
+
let totalMoved = 0;
|
|
964
|
+
let totalUpdated = 0;
|
|
592
965
|
let totalSkipped = 0;
|
|
966
|
+
const skipped = [];
|
|
593
967
|
|
|
594
968
|
for (const { filename, spec, opCount, changes } of results) {
|
|
595
969
|
const title = spec.info?.title || filename;
|
|
596
970
|
const hasChanges =
|
|
597
|
-
changes.added.length +
|
|
971
|
+
changes.added.length +
|
|
972
|
+
changes.deleted.length +
|
|
973
|
+
changes.moved.length +
|
|
974
|
+
changes.updated.length +
|
|
975
|
+
changes.skipped.length >
|
|
976
|
+
0;
|
|
598
977
|
|
|
599
978
|
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
600
979
|
console.log();
|
|
@@ -610,25 +989,99 @@ export async function run(_options, _cmd, ctx) {
|
|
|
610
989
|
for (const file of changes.deleted) {
|
|
611
990
|
console.log(` ${styles.err('−')} Deleted ${file}`);
|
|
612
991
|
}
|
|
992
|
+
for (const { from, to } of changes.moved) {
|
|
993
|
+
console.log(` ${styles.warn('→')} Moved ${from} to ${to}`);
|
|
994
|
+
}
|
|
995
|
+
for (const file of changes.updated) {
|
|
996
|
+
console.log(` ${styles.warn('~')} Updated ${file}`);
|
|
997
|
+
}
|
|
613
998
|
for (const { path: file, operationId } of changes.skipped) {
|
|
614
999
|
console.log(
|
|
615
1000
|
` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
|
|
616
1001
|
);
|
|
1002
|
+
skipped.push({ filename, path: file, operationId });
|
|
617
1003
|
}
|
|
618
1004
|
|
|
619
1005
|
totalAdded += changes.added.length;
|
|
620
1006
|
totalDeleted += changes.deleted.length;
|
|
1007
|
+
totalMoved += changes.moved.length;
|
|
1008
|
+
totalUpdated += changes.updated.length;
|
|
621
1009
|
totalSkipped += changes.skipped.length;
|
|
622
1010
|
}
|
|
623
1011
|
|
|
1012
|
+
return { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function run(_options, _cmd, ctx) {
|
|
1016
|
+
const { gitRoot } = ctx;
|
|
1017
|
+
const refDir = path.join(gitRoot, 'reference');
|
|
1018
|
+
|
|
1019
|
+
if (!fs.existsSync(refDir)) {
|
|
1020
|
+
styles.error('No reference/ directory found.');
|
|
1021
|
+
writeGithubActionsOutputs({
|
|
1022
|
+
'added-count': '0',
|
|
1023
|
+
'deleted-count': '0',
|
|
1024
|
+
'moved-count': '0',
|
|
1025
|
+
'updated-count': '0',
|
|
1026
|
+
'skipped-count': '0',
|
|
1027
|
+
skipped: [],
|
|
1028
|
+
'has-errors': 'true',
|
|
1029
|
+
});
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const results = syncOas(gitRoot);
|
|
1034
|
+
|
|
1035
|
+
if (!results) {
|
|
1036
|
+
styles.info('No OpenAPI spec files found in reference/.');
|
|
1037
|
+
writeGithubActionsOutputs({
|
|
1038
|
+
'added-count': '0',
|
|
1039
|
+
'deleted-count': '0',
|
|
1040
|
+
'moved-count': '0',
|
|
1041
|
+
'updated-count': '0',
|
|
1042
|
+
'skipped-count': '0',
|
|
1043
|
+
skipped: [],
|
|
1044
|
+
'has-errors': 'false',
|
|
1045
|
+
});
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } =
|
|
1050
|
+
printSyncResults(results);
|
|
1051
|
+
|
|
624
1052
|
console.log();
|
|
625
|
-
const total = totalAdded + totalDeleted;
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
1053
|
+
const total = totalAdded + totalDeleted + totalMoved + totalUpdated;
|
|
1054
|
+
const extra = [
|
|
1055
|
+
totalMoved > 0 ? `${totalMoved} moved` : null,
|
|
1056
|
+
totalUpdated > 0 ? `${totalUpdated} updated` : null,
|
|
1057
|
+
].filter(Boolean);
|
|
1058
|
+
const extraNote = extra.length ? `, ${extra.join(', ')}` : '';
|
|
1059
|
+
// A skip means a page couldn't be written where it should've gone — either
|
|
1060
|
+
// a spec-crafted path trying to escape reference/, or the destination
|
|
1061
|
+
// already existing in a way sync's own bookkeeping didn't expect. Neither
|
|
1062
|
+
// is something to quietly succeed past, so this fails the same way lint
|
|
1063
|
+
// and oas:validate already fail on a real problem, rather than leaving it
|
|
1064
|
+
// to whoever wraps this command in CI to notice and fail on it themselves.
|
|
1065
|
+
if (totalSkipped > 0) {
|
|
1066
|
+
const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : '';
|
|
1067
|
+
styles.error(`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`);
|
|
629
1068
|
} else if (total === 0) {
|
|
630
|
-
styles.
|
|
1069
|
+
styles.ok('Reference pages are already in sync.');
|
|
631
1070
|
} else {
|
|
632
|
-
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${
|
|
1071
|
+
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${extraNote}.`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
writeGithubActionsOutputs({
|
|
1075
|
+
'added-count': String(totalAdded),
|
|
1076
|
+
'deleted-count': String(totalDeleted),
|
|
1077
|
+
'moved-count': String(totalMoved),
|
|
1078
|
+
'updated-count': String(totalUpdated),
|
|
1079
|
+
'skipped-count': String(totalSkipped),
|
|
1080
|
+
skipped,
|
|
1081
|
+
'has-errors': String(totalSkipped > 0),
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
if (totalSkipped > 0) {
|
|
1085
|
+
process.exit(1);
|
|
633
1086
|
}
|
|
634
1087
|
}
|
|
@@ -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) {
|