@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
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');
|
|
@@ -56,29 +57,160 @@ function generateOperationId(method, pathStr) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* Identity key for an operation record (or an existing page's frontmatter),
|
|
61
|
+
* used everywhere operations/pages are looked up by operationId. `paths` and
|
|
62
|
+
* `webhooks` are separate namespaces in an OAS document, but both can have
|
|
63
|
+
* operationId omitted, so their synthetic `<method>_<name>` ids can
|
|
64
|
+
* legitimately collide (e.g. `POST /orders` and webhook `POST orders` both
|
|
65
|
+
* synthesize to `post_orders`) — the isWebhook flag disambiguates them so
|
|
66
|
+
* neither silently overwrites the other in an operationId-only Map.
|
|
67
|
+
*/
|
|
68
|
+
export function operationKey({ operationId, isWebhook }) {
|
|
69
|
+
return `${isWebhook ? 'webhook' : 'path'}:${operationId}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a `paths`/`webhooks` entry that's a Reference Object (OAS 3.1,
|
|
74
|
+
* `{ $ref: '#/components/pathItems/Name' }`) against the spec's own
|
|
75
|
+
* `components.pathItems`, following chained refs (a pathItem that is itself
|
|
76
|
+
* a $ref to another) until a literal Path Item is reached. Only same-document
|
|
77
|
+
* refs in that exact form are supported; anything else (external files,
|
|
78
|
+
* other pointer shapes, an unresolvable name, or a cycle) is left unresolved
|
|
79
|
+
* and quietly skipped by the caller, same as before this existed.
|
|
80
|
+
*/
|
|
81
|
+
function resolveLocalPathItemRef(entry, spec) {
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
let current = entry;
|
|
84
|
+
// Sibling fields (e.g. an inline operation) alongside a $ref are explicitly
|
|
85
|
+
// allowed in an OAS 3.1 Path Item Object — accumulate them from every hop
|
|
86
|
+
// in the chain so they aren't discarded once $ref is followed. A field
|
|
87
|
+
// declared at an outer/earlier hop wins over the same field found deeper
|
|
88
|
+
// in the chain (OAS itself leaves this "undefined" when both define it).
|
|
89
|
+
let overrides = {};
|
|
90
|
+
const finish = () => ({ ...current, ...overrides });
|
|
91
|
+
|
|
92
|
+
while (current && typeof current.$ref === 'string') {
|
|
93
|
+
const { $ref, ...siblings } = current;
|
|
94
|
+
overrides = { ...siblings, ...overrides };
|
|
95
|
+
|
|
96
|
+
if (seen.has(current.$ref)) return finish();
|
|
97
|
+
seen.add(current.$ref);
|
|
98
|
+
|
|
99
|
+
const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/);
|
|
100
|
+
if (!match) return finish();
|
|
101
|
+
|
|
102
|
+
let name;
|
|
103
|
+
try {
|
|
104
|
+
name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~');
|
|
105
|
+
} catch {
|
|
106
|
+
// Malformed percent-escape — leave unresolved rather than throwing and
|
|
107
|
+
// aborting the whole sync/lint run over one bad $ref.
|
|
108
|
+
return finish();
|
|
109
|
+
}
|
|
110
|
+
const resolved = spec.components?.pathItems?.[name];
|
|
111
|
+
if (!resolved) return finish();
|
|
112
|
+
|
|
113
|
+
current = resolved;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return finish();
|
|
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
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks`
|
|
175
|
+
* (callouts the API itself makes to a client-registered URL, not endpoints the
|
|
176
|
+
* API exposes — a separate top-level sibling of `paths` with the same
|
|
177
|
+
* Operation Object shape). The platform pages a webhook the same way it pages
|
|
178
|
+
* a path operation: a synthetic `post_<name>` operationId when none is given,
|
|
179
|
+
* grouped by its own tag or, absent one, its own category keyed by its raw
|
|
180
|
+
* name — never merged with `paths` operations of the same name.
|
|
181
|
+
* Returns a Map keyed by `operationKey()` -> { summary, description, tag,
|
|
182
|
+
* path, operationId, isWebhook }. For operations without an operationId, a
|
|
183
|
+
* synthetic one is generated from the method and path (or webhook name).
|
|
62
184
|
*/
|
|
63
185
|
export function extractOperations(spec) {
|
|
64
186
|
const ops = new Map();
|
|
65
|
-
const paths = spec.paths || {};
|
|
66
187
|
|
|
67
|
-
|
|
68
|
-
for (const [
|
|
69
|
-
|
|
188
|
+
function collect(entries, isWebhook) {
|
|
189
|
+
for (const [pathStr, rawItem] of Object.entries(entries)) {
|
|
190
|
+
const methods = resolveLocalPathItemRef(rawItem, spec);
|
|
70
191
|
|
|
71
|
-
const
|
|
192
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
193
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
72
194
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
195
|
+
const operationId = operation.operationId || generateOperationId(method, pathStr);
|
|
196
|
+
|
|
197
|
+
ops.set(operationKey({ operationId, isWebhook }), {
|
|
198
|
+
operationId,
|
|
199
|
+
summary: operation.summary || null,
|
|
200
|
+
description: operation.description || null,
|
|
201
|
+
// The platform groups by the first *non-empty* tag.
|
|
202
|
+
tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null,
|
|
203
|
+
path: pathStr,
|
|
204
|
+
isWebhook,
|
|
205
|
+
xInternal: resolveXInternal(operation, spec),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
79
208
|
}
|
|
80
209
|
}
|
|
81
210
|
|
|
211
|
+
collect(spec.paths || {}, false);
|
|
212
|
+
collect(spec.webhooks || {}, true);
|
|
213
|
+
|
|
82
214
|
return ops;
|
|
83
215
|
}
|
|
84
216
|
|
|
@@ -179,37 +311,363 @@ function isWithin(baseDir, target) {
|
|
|
179
311
|
);
|
|
180
312
|
}
|
|
181
313
|
|
|
182
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Render a frontmatter-only page. `matter.stringify` always appends a blank
|
|
316
|
+
* body after the closing fence (even for an empty body); the platform's own
|
|
317
|
+
* generated pages end immediately after the fence with no trailing newline,
|
|
318
|
+
* so trim it to match.
|
|
319
|
+
*/
|
|
320
|
+
function stringifyFrontmatter(frontmatter) {
|
|
321
|
+
return matter.stringify('', frontmatter).replace(/\n+$/, '');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
|
|
183
325
|
const frontmatter = {
|
|
184
326
|
api: {
|
|
185
327
|
file: oasFilename,
|
|
186
328
|
operationId,
|
|
329
|
+
// Marks the page as a webhook (the API calling out to the client)
|
|
330
|
+
// rather than a path operation (the client calling the API), matching
|
|
331
|
+
// what the platform stamps on a page generated from `webhooks`.
|
|
332
|
+
...(isWebhook ? { webhook: true } : {}),
|
|
187
333
|
},
|
|
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,
|
|
188
340
|
};
|
|
189
341
|
|
|
190
|
-
return
|
|
342
|
+
return stringifyFrontmatter(frontmatter);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Build a category landing page (mirrors what the ReadMe platform generates on
|
|
347
|
+
* OAS upload): `title` is the tag name for a tagged group, or the raw path for
|
|
348
|
+
* an untagged path-derived group (see `operationGroup`); `excerpt`, when given,
|
|
349
|
+
* is the tag's description from the spec's top-level `tags` array.
|
|
350
|
+
*/
|
|
351
|
+
function buildTagIndexContent(title, description) {
|
|
352
|
+
const frontmatter = { title };
|
|
353
|
+
if (description) frontmatter.excerpt = description;
|
|
354
|
+
// As with operation pages, upload always stamps hidden: false on new pages.
|
|
355
|
+
frontmatter.hidden = false;
|
|
356
|
+
|
|
357
|
+
return stringifyFrontmatter(frontmatter);
|
|
358
|
+
}
|
|
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
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The category-folder grouping for an operation. A tagged operation groups
|
|
381
|
+
* under its own tag, as before. An untagged operation groups under a folder
|
|
382
|
+
* derived from its path, with the raw path as the category page's title — one
|
|
383
|
+
* folder per unique path, not a single shared bucket. This mirrors the
|
|
384
|
+
* platform's own OAS-upload output: untagged operations are never lumped into
|
|
385
|
+
* one "Other" folder.
|
|
386
|
+
*/
|
|
387
|
+
function operationGroup(op) {
|
|
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';
|
|
390
|
+
return { folder, title: op.path };
|
|
391
|
+
}
|
|
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
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Collect every slug already used across the entire reference/ tree, as a
|
|
419
|
+
* lowercase-slug -> owner-count map. Reference page slugs share one flat
|
|
420
|
+
* namespace (docs/ is a separate namespace and is not consulted), so a
|
|
421
|
+
* generated operation slug must be unique against all of them. A page's slug
|
|
422
|
+
* is its filename without `.md`; a category page's slug (a folder containing
|
|
423
|
+
* `index.md`) is the folder name.
|
|
424
|
+
*
|
|
425
|
+
* A count, not a Set, because two existing pages or folders can already share
|
|
426
|
+
* a slug (hand-authored content, or content that predates this uniqueness
|
|
427
|
+
* logic) — a Set would collapse them to one entry, and releasing one owner
|
|
428
|
+
* (see `releaseSlug`) would incorrectly free the slug while the other owner
|
|
429
|
+
* still holds it.
|
|
430
|
+
*/
|
|
431
|
+
function collectReferenceSlugs(refDir) {
|
|
432
|
+
const counts = new Map();
|
|
433
|
+
|
|
434
|
+
function walk(dir) {
|
|
435
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
436
|
+
const full = path.join(dir, entry);
|
|
437
|
+
let stat;
|
|
438
|
+
try {
|
|
439
|
+
stat = fs.statSync(full);
|
|
440
|
+
} catch {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (stat.isDirectory()) {
|
|
444
|
+
walk(full);
|
|
445
|
+
} else if (entry.endsWith('.md')) {
|
|
446
|
+
// A folder's index.md contributes the folder name as a slug; any other
|
|
447
|
+
// page contributes its own filename.
|
|
448
|
+
const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md');
|
|
449
|
+
takeSlug(counts, slug);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
walk(refDir);
|
|
455
|
+
return counts;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isSlugTaken(takenSlugs, slug) {
|
|
459
|
+
return (takenSlugs.get(slug.toLowerCase()) || 0) > 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Record one more owner of `slug`. */
|
|
463
|
+
function takeSlug(takenSlugs, slug) {
|
|
464
|
+
const key = slug.toLowerCase();
|
|
465
|
+
takenSlugs.set(key, (takenSlugs.get(key) || 0) + 1);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Record one fewer owner of `slug`; only fully frees it once every owner is gone. */
|
|
469
|
+
function releaseSlug(takenSlugs, slug) {
|
|
470
|
+
const key = slug.toLowerCase();
|
|
471
|
+
const remaining = (takenSlugs.get(key) || 0) - 1;
|
|
472
|
+
if (remaining > 0) takenSlugs.set(key, remaining);
|
|
473
|
+
else takenSlugs.delete(key);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Reserve a unique reference slug. `index` is never usable by an operation (it's
|
|
478
|
+
* reserved for the tag category page), and any slug already present in the
|
|
479
|
+
* reference namespace gets a numeric suffix (`-1`, `-2`, ...) until it's free.
|
|
480
|
+
* The chosen slug gains an owner in `takenSlugs` so later operations see it.
|
|
481
|
+
*/
|
|
482
|
+
function reserveSlug(takenSlugs, base) {
|
|
483
|
+
let chosen = base;
|
|
484
|
+
if (base === 'index' || isSlugTaken(takenSlugs, base)) {
|
|
485
|
+
let n = 1;
|
|
486
|
+
while (isSlugTaken(takenSlugs, `${base}-${n}`)) n += 1;
|
|
487
|
+
chosen = `${base}-${n}`;
|
|
488
|
+
}
|
|
489
|
+
takeSlug(takenSlugs, chosen);
|
|
490
|
+
return chosen;
|
|
491
|
+
}
|
|
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) });
|
|
191
618
|
}
|
|
192
619
|
|
|
193
620
|
/**
|
|
194
621
|
* Run the sync for a single OAS file. Returns changes for that file.
|
|
622
|
+
*
|
|
623
|
+
* `takenSlugs` is the reference-wide set of slugs already in use; it is read and
|
|
624
|
+
* mutated so slugs stay unique across every spec processed in one sync run.
|
|
195
625
|
*/
|
|
196
|
-
function syncOneOas(refDir, oasFilename, spec) {
|
|
626
|
+
function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
|
|
197
627
|
const specOps = extractOperations(spec);
|
|
198
628
|
const infoTitle = safeSegment(
|
|
199
629
|
spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
|
|
200
630
|
'api',
|
|
201
631
|
);
|
|
202
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
|
+
|
|
203
642
|
const existingPages = collectExistingPages(refDir).filter(
|
|
204
643
|
(p) => p.data.api.file === oasFilename,
|
|
205
644
|
);
|
|
206
645
|
|
|
207
646
|
const pagesByOpId = new Map();
|
|
208
647
|
for (const page of existingPages) {
|
|
209
|
-
pagesByOpId.set(
|
|
648
|
+
pagesByOpId.set(
|
|
649
|
+
operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
|
|
650
|
+
page,
|
|
651
|
+
);
|
|
210
652
|
}
|
|
211
653
|
|
|
212
|
-
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;
|
|
660
|
+
|
|
661
|
+
// Tag descriptions from the spec's top-level `tags` array, used for the
|
|
662
|
+
// per-tag category landing page (index.md).
|
|
663
|
+
const tagDescriptions = new Map(
|
|
664
|
+
(Array.isArray(spec.tags) ? spec.tags : [])
|
|
665
|
+
.filter((t) => t && t.name)
|
|
666
|
+
.map((t) => [t.name, t.description || null]),
|
|
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));
|
|
213
671
|
|
|
214
672
|
// Deletes: pages referencing operations that no longer exist.
|
|
215
673
|
for (const [opId, page] of pagesByOpId) {
|
|
@@ -217,41 +675,239 @@ function syncOneOas(refDir, oasFilename, spec) {
|
|
|
217
675
|
fs.unlinkSync(page.filePath);
|
|
218
676
|
|
|
219
677
|
const pageDir = path.dirname(page.filePath);
|
|
220
|
-
|
|
221
|
-
|
|
678
|
+
// A legacy operation page can be literally named index.md (predating
|
|
679
|
+
// the "index is reserved for the category page" convention). Two
|
|
680
|
+
// different things need two different values here: pageSlug is what a
|
|
681
|
+
// pre-refactor tool would have actually written into pageDir's own
|
|
682
|
+
// _order.yaml ("index", the filename) — that's what removeFromOrder
|
|
683
|
+
// must remove. referenceSlug is what the reference-wide slug map
|
|
684
|
+
// reserved for it (its folder name, like any index.md — see
|
|
685
|
+
// collectReferenceSlugs) — that's what releaseSlug must free.
|
|
686
|
+
const isIndexPage = path.basename(page.filePath) === 'index.md';
|
|
687
|
+
const pageSlug = path.basename(page.filePath, '.md');
|
|
688
|
+
const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
|
|
689
|
+
removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
|
|
690
|
+
releaseSlug(takenSlugs, referenceSlug);
|
|
222
691
|
|
|
223
692
|
changes.deleted.push(page.relativePath);
|
|
224
693
|
}
|
|
225
694
|
}
|
|
226
695
|
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
696
|
+
// Ensure every group (a tag, or a path-derived bucket for untagged
|
|
697
|
+
// operations) present in the spec has its category landing page (index.md)
|
|
698
|
+
// and is ordered — independent of whether its operation pages are new. Doing
|
|
699
|
+
// this as its own pass (rather than only when creating a new op page) backfills
|
|
700
|
+
// category pages for references first synced by a CLI version that didn't
|
|
701
|
+
// generate them, and recreates one that was deleted.
|
|
702
|
+
const groupsByFolder = new Map();
|
|
703
|
+
for (const op of specOps.values()) {
|
|
704
|
+
const { folder, title } = operationGroup(op);
|
|
705
|
+
if (!groupsByFolder.has(folder)) {
|
|
706
|
+
groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Order groups the way the platform does: a tag keeps the position it's
|
|
711
|
+
// declared in the spec's own top-level `tags` array, not the order its
|
|
712
|
+
// operations happen to appear in `paths`. A group with no declared position
|
|
713
|
+
// (an untagged path-derived group, or a tag used by an operation but never
|
|
714
|
+
// listed in `tags`) keeps its natural encounter order, appended after every
|
|
715
|
+
// declared tag.
|
|
716
|
+
const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
|
|
717
|
+
.filter((t) => t && t.name)
|
|
718
|
+
.map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
|
|
719
|
+
const orderedFolders = [
|
|
720
|
+
...declaredOrder.filter((folder) => groupsByFolder.has(folder)),
|
|
721
|
+
...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)),
|
|
722
|
+
];
|
|
723
|
+
|
|
724
|
+
const groupDirs = new Map();
|
|
725
|
+
const createdIndexes = new Set();
|
|
726
|
+
for (const folder of orderedFolders) {
|
|
727
|
+
const { title, description } = groupsByFolder.get(folder);
|
|
728
|
+
const actualFolder = foldersBySlug.get(folder) || folder;
|
|
729
|
+
const pageDir = path.join(refDir, infoTitle, actualFolder);
|
|
730
|
+
if (!isWithin(refDir, pageDir)) continue;
|
|
731
|
+
groupDirs.set(folder, pageDir);
|
|
732
|
+
|
|
733
|
+
const indexPath = path.join(pageDir, 'index.md');
|
|
734
|
+
if (!fs.existsSync(indexPath)) {
|
|
735
|
+
// Never overwrite an existing index.md — it may be a hand-written category.
|
|
736
|
+
fs.mkdirSync(pageDir, { recursive: true });
|
|
737
|
+
fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
|
|
738
|
+
changes.added.push(path.relative(refDir, indexPath));
|
|
739
|
+
createdIndexes.add(indexPath);
|
|
740
|
+
// The category page's slug is the folder name; reserve it so no operation
|
|
741
|
+
// takes it. Only when just-created — an existing index.md was already
|
|
742
|
+
// counted by collectReferenceSlugs's initial disk walk.
|
|
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));
|
|
754
|
+
}
|
|
755
|
+
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
|
|
756
|
+
addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
|
|
757
|
+
}
|
|
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
|
+
}
|
|
231
798
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
|
|
813
|
+
// Adds: operation pages with no page yet. Title/excerpt are owned by the OAS
|
|
814
|
+
// spec at render time, so generated pages carry only the api reference. Slugs
|
|
815
|
+
// are lowercased to match the platform's OAS-upload output.
|
|
816
|
+
for (const [key, op] of specOps) {
|
|
817
|
+
if (pagesByOpId.has(key)) continue;
|
|
818
|
+
|
|
819
|
+
const { folder } = operationGroup(op);
|
|
820
|
+
const pageDir = path.join(refDir, infoTitle, foldersBySlug.get(folder) || folder);
|
|
821
|
+
// Reference slugs share one flat namespace, so uniquify against every slug
|
|
822
|
+
// already in reference/ — a collision (or the reserved `index` slug) gets a
|
|
823
|
+
// numeric suffix rather than being skipped.
|
|
824
|
+
const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
|
|
235
825
|
const pagePath = path.join(pageDir, `${slug}.md`);
|
|
236
826
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// one. Skipping (rather than clobbering) keeps repeated syncs stable.
|
|
827
|
+
// Guard against a spec-crafted name escaping reference/, or a stale slug set
|
|
828
|
+
// vs. disk. reserveSlug already prevents slug collisions.
|
|
240
829
|
if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
|
|
241
|
-
changes.skipped.push({ path: path.relative(refDir, pagePath), operationId:
|
|
830
|
+
changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
|
|
242
831
|
continue;
|
|
243
832
|
}
|
|
244
833
|
fs.mkdirSync(pageDir, { recursive: true });
|
|
245
834
|
|
|
246
|
-
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
|
+
});
|
|
247
841
|
fs.writeFileSync(pagePath, content);
|
|
248
842
|
|
|
249
843
|
addToOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
250
|
-
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
|
|
251
844
|
|
|
252
845
|
changes.added.push(path.relative(refDir, pagePath));
|
|
846
|
+
opPaths.set(key, pagePath);
|
|
253
847
|
}
|
|
254
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));
|
|
870
|
+
}
|
|
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
|
+
|
|
255
911
|
return changes;
|
|
256
912
|
}
|
|
257
913
|
|
|
@@ -264,7 +920,8 @@ function syncOneOas(refDir, oasFilename, spec) {
|
|
|
264
920
|
*
|
|
265
921
|
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
266
922
|
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
267
|
-
* changes: { added: string[], deleted: string[]
|
|
923
|
+
* changes: { added: string[], deleted: string[], moved: { from, to }[],
|
|
924
|
+
* updated: string[], skipped: { path, operationId }[] } }>}
|
|
268
925
|
* Returns null if there's no reference/ dir or no specs.
|
|
269
926
|
*/
|
|
270
927
|
export function syncOas(input) {
|
|
@@ -275,41 +932,48 @@ export function syncOas(input) {
|
|
|
275
932
|
const oasFiles = findOasFiles(refDir);
|
|
276
933
|
if (oasFiles.length === 0) return null;
|
|
277
934
|
|
|
935
|
+
// Reference slugs share one flat namespace across every spec, so build the set
|
|
936
|
+
// of in-use slugs once and let each spec read/extend it.
|
|
937
|
+
const takenSlugs = collectReferenceSlugs(refDir);
|
|
278
938
|
const allChanges = [];
|
|
279
939
|
|
|
280
940
|
for (const { filename, spec } of oasFiles) {
|
|
281
941
|
const ops = extractOperations(spec);
|
|
282
|
-
const changes = syncOneOas(refDir, filename, spec);
|
|
942
|
+
const changes = syncOneOas(refDir, filename, spec, takenSlugs);
|
|
283
943
|
allChanges.push({ filename, spec, opCount: ops.size, changes });
|
|
284
944
|
}
|
|
285
945
|
|
|
286
946
|
return allChanges;
|
|
287
947
|
}
|
|
288
948
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
styles.info('No OpenAPI spec files found in reference/.');
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
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) {
|
|
305
961
|
let totalAdded = 0;
|
|
306
962
|
let totalDeleted = 0;
|
|
963
|
+
let totalMoved = 0;
|
|
964
|
+
let totalUpdated = 0;
|
|
307
965
|
let totalSkipped = 0;
|
|
966
|
+
const skipped = [];
|
|
308
967
|
|
|
309
968
|
for (const { filename, spec, opCount, changes } of results) {
|
|
310
969
|
const title = spec.info?.title || filename;
|
|
311
970
|
const hasChanges =
|
|
312
|
-
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;
|
|
313
977
|
|
|
314
978
|
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
315
979
|
console.log();
|
|
@@ -325,25 +989,99 @@ export async function run(_options, _cmd, ctx) {
|
|
|
325
989
|
for (const file of changes.deleted) {
|
|
326
990
|
console.log(` ${styles.err('−')} Deleted ${file}`);
|
|
327
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
|
+
}
|
|
328
998
|
for (const { path: file, operationId } of changes.skipped) {
|
|
329
999
|
console.log(
|
|
330
1000
|
` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
|
|
331
1001
|
);
|
|
1002
|
+
skipped.push({ filename, path: file, operationId });
|
|
332
1003
|
}
|
|
333
1004
|
|
|
334
1005
|
totalAdded += changes.added.length;
|
|
335
1006
|
totalDeleted += changes.deleted.length;
|
|
1007
|
+
totalMoved += changes.moved.length;
|
|
1008
|
+
totalUpdated += changes.updated.length;
|
|
336
1009
|
totalSkipped += changes.skipped.length;
|
|
337
1010
|
}
|
|
338
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
|
+
|
|
339
1052
|
console.log();
|
|
340
|
-
const total = totalAdded + totalDeleted;
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
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.`);
|
|
344
1068
|
} else if (total === 0) {
|
|
345
|
-
styles.
|
|
1069
|
+
styles.ok('Reference pages are already in sync.');
|
|
346
1070
|
} else {
|
|
347
|
-
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);
|
|
348
1086
|
}
|
|
349
1087
|
}
|