@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.
@@ -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
- * Extract operations from an OAS spec.
60
- * Returns a Map of operationId -> { summary, description, tag, operationId }.
61
- * For operations without an operationId, a synthetic one is generated from the method and path.
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
- for (const [pathStr, methods] of Object.entries(paths)) {
68
- for (const [method, operation] of Object.entries(methods)) {
69
- if (!HTTP_METHODS.has(method)) continue;
188
+ function collect(entries, isWebhook) {
189
+ for (const [pathStr, rawItem] of Object.entries(entries)) {
190
+ const methods = resolveLocalPathItemRef(rawItem, spec);
70
191
 
71
- const operationId = operation.operationId || generateOperationId(method, pathStr);
192
+ for (const [method, operation] of Object.entries(methods)) {
193
+ if (!HTTP_METHODS.has(method)) continue;
72
194
 
73
- ops.set(operationId, {
74
- operationId,
75
- summary: operation.summary || null,
76
- description: operation.description || null,
77
- tag: (operation.tags && operation.tags[0]) || null,
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
- function buildPageContent({ oasFilename, operationId }) {
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 matter.stringify('', frontmatter);
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(page.data.api.operationId, page);
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
- const slug = path.basename(page.filePath, '.md');
221
- removeFromOrder(path.join(pageDir, '_order.yaml'), slug);
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
- // Adds: operations with no page yet. Title/excerpt are owned by the OAS spec
228
- // at render time, so generated pages carry only the api reference.
229
- for (const [opId, op] of specOps) {
230
- if (pagesByOpId.has(opId)) continue;
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
- const tag = safeSegment(op.tag || 'Other', 'Other');
233
- const slug = safeSegment(opId, 'operation');
234
- const pageDir = path.join(refDir, infoTitle, tag);
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
- // Never overwrite an existing file: it belongs to a manual page, another
238
- // spec, or a different operation whose sanitized name collides with this
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: opId });
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({ oasFilename, operationId: opId });
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
- export async function run(_options, _cmd, ctx) {
290
- const { gitRoot } = ctx;
291
- const refDir = path.join(gitRoot, 'reference');
292
-
293
- if (!fs.existsSync(refDir)) {
294
- styles.error('No reference/ directory found.');
295
- process.exit(1);
296
- }
297
-
298
- const results = syncOas(gitRoot);
299
-
300
- if (!results) {
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 + changes.deleted.length + changes.skipped.length > 0;
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 skippedNote = totalSkipped > 0 ? `, ${totalSkipped} skipped` : '';
342
- if (total === 0 && totalSkipped === 0) {
343
- styles.ok('Reference pages are already in sync.');
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.warning(`No pages synced; ${totalSkipped} skipped (destination already exists).`);
1069
+ styles.ok('Reference pages are already in sync.');
346
1070
  } else {
347
- styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${skippedNote}.`);
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
  }