@readme/cli 0.0.30 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/package.json +7 -2
- package/src/commands/import.js +128 -73
- package/src/commands/import.staging.test.js +74 -2
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-bundle.js +85 -0
- package/src/commands/oas-sync.js +501 -48
- package/src/commands/oas-validate.js +23 -0
- package/src/gha-pre.js +29 -0
- package/src/gha.js +104 -0
- package/src/utils/gha-output.js +32 -0
- package/src/utils/lint.js +30 -15
- package/src/validators/oas-reference.js +24 -3
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
|
}
|