@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.
@@ -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
- tag: (operation.tags && operation.tags[0]) || null,
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
- // Mirror the platform's OAS-upload behavior: a newly added endpoint is
278
- // always written `hidden: false`, even when its tag and siblings are
279
- // `hidden: true`. The backend does not infer this from a missing field, so
280
- // it must be written explicitly.
281
- //
282
- // @todo Honor the `x-internal` OpenAPI extension for page visibility, to
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').toLowerCase(), title: op.tag };
320
- const folder = safeSegment(op.path.replace(/[/{}]/g, ''), 'operation').toLowerCase();
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').toLowerCase());
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 pageDir = path.join(refDir, infoTitle, folder);
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, folder);
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'), folder);
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({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook });
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
- export async function run(_options, _cmd, ctx) {
575
- const { gitRoot } = ctx;
576
- const refDir = path.join(gitRoot, 'reference');
577
-
578
- if (!fs.existsSync(refDir)) {
579
- styles.error('No reference/ directory found.');
580
- process.exit(1);
581
- }
582
-
583
- const results = syncOas(gitRoot);
584
-
585
- if (!results) {
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 + 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;
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 skippedNote = totalSkipped > 0 ? `, ${totalSkipped} skipped` : '';
627
- if (total === 0 && totalSkipped === 0) {
628
- 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.`);
629
1068
  } else if (total === 0) {
630
- styles.warning(`No pages synced; ${totalSkipped} skipped (destination already exists).`);
1069
+ styles.ok('Reference pages are already in sync.');
631
1070
  } else {
632
- 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);
633
1086
  }
634
1087
  }