@readme/cli 0.0.30 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,33 @@ Starts a local dev server so you can preview your docs as you write them. Watche
50
50
  npx @readme/cli dev
51
51
  ```
52
52
 
53
+ ## GitHub Action
54
+
55
+ This repo also publishes itself as a GitHub Action, so a workflow can run `lint`, `oas:validate`, or `oas:sync` directly, pinned to a version, without an `npx` install step on every run:
56
+
57
+ ```yaml
58
+ - uses: readmeio/cli@v1
59
+ with:
60
+ readme: oas:sync
61
+ ```
62
+
63
+ The `readme` input is the same command you'd run on the CLI, e.g. `lint`, `oas:validate --dereference`, or `oas:sync`. Each command exits non-zero on a real problem, same as running it locally, and publishes its results as step outputs — `has-errors`, `skipped-count`, `total-errors`, and more (see [`action.yml`](./action.yml) for the full list) — so a later step can check them directly instead of parsing log output:
64
+
65
+ ```yaml
66
+ - name: Sync reference pages from OpenAPI specs
67
+ id: sync
68
+ uses: readmeio/cli@v1
69
+ with:
70
+ readme: oas:sync
71
+ ```
72
+
73
+ ```yaml
74
+ - if: steps.sync.outputs.skipped-count != '0'
75
+ run: echo "::error::oas:sync skipped a page — see the sync step above."
76
+ ```
77
+
78
+ Note: the first run of a workflow using this Action installs one extra dependency (`@readme/markdown`) behind the scenes before your command runs — this is expected, and only adds a few seconds.
79
+
53
80
  ## Feedback
54
81
 
55
82
  If something doesn't work right, or if you have suggestions, please [open an issue](https://github.com/readmeio/cli/issues). We're actively building this and want to hear from you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readme/cli",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "The ReadMe CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "start": "node bin/readme.js",
28
- "test": "node --test src/**/*.test.js test/*.test.js"
28
+ "test": "node --test src/**/*.test.js test/*.test.js",
29
+ "build:gha": "ncc build src/gha.js -o dist-gha --external @readme/markdown"
29
30
  },
30
31
  "engines": {
31
32
  "node": ">=22"
@@ -56,5 +57,8 @@
56
57
  "react": "^18.3.1",
57
58
  "react-dom": "^18.3.1",
58
59
  "update-notifier": "^7.3.1"
60
+ },
61
+ "devDependencies": {
62
+ "@vercel/ncc": "^0.45.0"
59
63
  }
60
64
  }
@@ -1,11 +1,9 @@
1
- import { createRequire } from 'node:module'
2
1
  import { collectFiles, runValidators } from '../utils/lint.js'
3
2
  import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
4
3
  import { printHeader, isAgenticCli } from '../utils/eyes.js'
5
4
  import * as styles from '../utils/styles.js'
6
-
7
- const require = createRequire(import.meta.url)
8
- const pkg = require('../../package.json')
5
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js'
6
+ import pkg from '../../package.json' with { type: 'json' }
9
7
 
10
8
  export const command = 'lint'
11
9
  export const order = 1
@@ -59,6 +57,8 @@ export async function run(options, _cmd, ctx) {
59
57
 
60
58
  reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
61
59
 
60
+ writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
61
+
62
62
  if (hasErrors) {
63
63
  // Wait for stdout to flush (important when piped to a file), then exit
64
64
  await new Promise((resolve) => process.stdout.write('', resolve))
@@ -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
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import OASNormalize from 'oas-normalize';
4
4
  import { findOasFiles, extractOperations } from './oas-sync.js';
5
5
  import * as styles from '../utils/styles.js';
6
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js';
6
7
 
7
8
  export const command = 'oas:validate';
8
9
  export const order = 3;
@@ -182,6 +183,13 @@ export async function run(options, _cmd, ctx) {
182
183
 
183
184
  if (!fs.existsSync(refDir)) {
184
185
  styles.error('No reference/ directory found.');
186
+ writeGithubActionsOutputs({
187
+ 'has-errors': 'true',
188
+ 'total-errors': '0',
189
+ 'total-warnings': '0',
190
+ 'total-valid': '0',
191
+ 'file-count': '0',
192
+ });
185
193
  process.exit(1);
186
194
  }
187
195
 
@@ -189,11 +197,26 @@ export async function run(options, _cmd, ctx) {
189
197
 
190
198
  if (!result) {
191
199
  styles.info('No OpenAPI spec files found in reference/.');
200
+ writeGithubActionsOutputs({
201
+ 'has-errors': 'false',
202
+ 'total-errors': '0',
203
+ 'total-warnings': '0',
204
+ 'total-valid': '0',
205
+ 'file-count': '0',
206
+ });
192
207
  return;
193
208
  }
194
209
 
195
210
  const { totalErrors, totalWarnings, totalValid, fileCount } = result;
196
211
 
212
+ writeGithubActionsOutputs({
213
+ 'has-errors': String(totalErrors > 0),
214
+ 'total-errors': String(totalErrors),
215
+ 'total-warnings': String(totalWarnings),
216
+ 'total-valid': String(totalValid),
217
+ 'file-count': String(fileCount),
218
+ });
219
+
197
220
  console.log();
198
221
  if (totalErrors > 0) {
199
222
  const parts = [`${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'}`];
package/src/gha-pre.js ADDED
@@ -0,0 +1,29 @@
1
+ // Pre step for the readmeio/cli GitHub Action (see action.yml's runs.pre).
2
+ //
3
+ // dist-gha/index.js (built by the build:gha script) excludes @readme/markdown
4
+ // from its bundle: it's a ~25MB package with 50+ dependencies (including
5
+ // tailwindcss and postcss) pulled in for one narrow helper the "lint" command
6
+ // uses, mdxishTags, and bundling it whole breaks on its embedded CSS/asset
7
+ // references. Rather than vendoring that whole tree into this repo, this step
8
+ // installs just that one package into the action's own directory immediately
9
+ // before dist-gha/index.js runs, so plain Node module resolution finds it.
10
+ //
11
+ // GITHUB_ACTION_PATH looked like the natural way to find that directory (and
12
+ // is what a version of this file first tried), but it came back undefined
13
+ // when actually tested on a real Actions runner. import.meta.dirname doesn't
14
+ // depend on the runner setting anything at all -- it's just "where is this
15
+ // file" -- and it's the same approach readmeio/rdme's own pre-step
16
+ // (bin/write-gha-pjson.js) already uses.
17
+ import { execFileSync } from 'node:child_process';
18
+ import { readFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ const actionPath = path.resolve(import.meta.dirname, '..');
22
+ const pkg = JSON.parse(readFileSync(path.join(actionPath, 'package.json'), 'utf-8'));
23
+ const range = pkg.dependencies['@readme/markdown'];
24
+
25
+ execFileSync(
26
+ 'npm',
27
+ ['install', '--no-save', '--no-audit', '--no-fund', '--prefix', actionPath, `@readme/markdown@${range}`],
28
+ { stdio: 'inherit' },
29
+ );
package/src/gha.js ADDED
@@ -0,0 +1,104 @@
1
+ // Entrypoint for the readmeio/cli GitHub Action. Deliberately does NOT go
2
+ // through src/cli.js — that bootstraps the full CLI by scanning src/commands/
3
+ // on disk at runtime (fs.readdirSync + dynamic import), which a bundler can't
4
+ // follow statically. This file instead imports just the three commands the
5
+ // Action exposes directly, so `ncc build` can produce one dependency-free
6
+ // file with no runtime install step (see package.json's build:gha script).
7
+ //
8
+ // Takes one input, `readme` (env var INPUT_README per GitHub Actions'
9
+ // convention), a plain command string such as "lint" or
10
+ // "oas:validate --dereference". Split into a command name and flags; there's
11
+ // no need for a full argument parser since each of these three commands only
12
+ // ever takes simple boolean flags.
13
+ //
14
+ // Each command's own run() already writes its structured results (has-errors,
15
+ // skipped-count, etc.) to GITHUB_OUTPUT itself, via utils/gha-output.js's
16
+ // GITHUB_ACTIONS-detecting helper — so this file just needs to call run() and
17
+ // let it do what it already does, exit code included.
18
+
19
+ import * as lint from './commands/lint.js';
20
+ import * as oasValidate from './commands/oas-validate.js';
21
+ import * as oasSync from './commands/oas-sync.js';
22
+ import { writeGithubActionsOutputs } from './utils/gha-output.js';
23
+
24
+ // Each command's run(options, cmd, ctx) only ever reads specific boolean
25
+ // flags off `options` (confirmed by reading all three) and never uses `cmd`
26
+ // (the commander Command instance normal CLI invocation would pass) — so a
27
+ // plain options object and a null cmd are enough here.
28
+ const COMMANDS = {
29
+ 'lint': { run: lint.run, flags: ['json', 'github', 'fix'] },
30
+ 'validate': { run: lint.run, flags: ['json', 'github', 'fix'] }, // alias, matches src/commands/lint.js
31
+ 'oas:validate': { run: oasValidate.run, flags: ['dereference'] },
32
+ 'oas:sync': { run: oasSync.run, flags: [] },
33
+ };
34
+
35
+ function parseInput(input) {
36
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
37
+ const [command, ...rest] = tokens;
38
+ return { command, rest };
39
+ }
40
+
41
+ export async function run() {
42
+ const input = process.env.INPUT_README || '';
43
+ const { command, rest } = parseInput(input);
44
+
45
+ const entry = COMMANDS[command];
46
+ if (!entry) {
47
+ console.error(
48
+ `::error::Unknown or unsupported command "${command || '(empty)'}" for the \`readme\` input. ` +
49
+ `Supported: ${[...new Set(Object.keys(COMMANDS).filter((k) => k !== 'validate'))].join(', ')}.`,
50
+ );
51
+ writeGithubActionsOutputs({ 'has-errors': 'true' });
52
+ process.exit(1);
53
+ }
54
+
55
+ // Every trailing token must be exactly "--<a supported flag>" — anything
56
+ // else (a short option like "-d", a malformed token, a misspelled flag)
57
+ // is rejected rather than silently dropped. An earlier version of this
58
+ // only checked tokens that already happened to start with "--", which let
59
+ // anything shaped differently (like "-d") skip validation entirely and
60
+ // run the command with default options with no indication anything was
61
+ // wrong.
62
+ const flags = new Set();
63
+ const badTokens = [];
64
+ for (const token of rest) {
65
+ const flag = token.startsWith('--') ? token.slice(2) : null;
66
+ if (flag && entry.flags.includes(flag)) {
67
+ flags.add(flag);
68
+ } else {
69
+ badTokens.push(token);
70
+ }
71
+ }
72
+
73
+ if (badTokens.length > 0) {
74
+ console.error(
75
+ `::error::Unknown option${badTokens.length > 1 ? 's' : ''} ${badTokens.map((t) => `"${t}"`).join(', ')} for "${command}". ` +
76
+ `Supported: ${entry.flags.length > 0 ? entry.flags.map((f) => `--${f}`).join(', ') : '(none)'}.`,
77
+ );
78
+ writeGithubActionsOutputs({ 'has-errors': 'true' });
79
+ process.exit(1);
80
+ }
81
+
82
+ const options = {};
83
+ for (const flag of entry.flags) options[flag] = flags.has(flag);
84
+
85
+ // The customer's repo, checked out by their own workflow before this
86
+ // action runs — not this action's own (separately checked-out) source.
87
+ const gitRoot = process.env.GITHUB_WORKSPACE || process.cwd();
88
+
89
+ await entry.run(options, null, { gitRoot });
90
+ }
91
+
92
+ run().catch((err) => {
93
+ console.error(err);
94
+ // A thrown error (as opposed to a command's own controlled process.exit)
95
+ // means whatever run() was doing never reached its own
96
+ // writeGithubActionsOutputs call, so this is the last chance to publish
97
+ // has-errors — without it, a downstream `if: always()` step would see an
98
+ // empty string, on exactly the failure path where that check matters
99
+ // most. Command-specific fields (skipped-count, results, etc.) aren't
100
+ // meaningful here since they were never computed, so has-errors is all
101
+ // this can honestly guarantee.
102
+ writeGithubActionsOutputs({ 'has-errors': 'true' });
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+
4
+ /**
5
+ * Writes each field as a GitHub Actions step output, so a workflow can read
6
+ * e.g. `steps.sync.outputs.skipped-count` instead of scraping printed text.
7
+ * A no-op anywhere else (a plain local CLI run, or CI systems other than
8
+ * GitHub Actions), detected the same way GitHub's own `@actions/core`
9
+ * toolkit does: the `GITHUB_ACTIONS` env var GitHub sets on every runner,
10
+ * plus the `GITHUB_OUTPUT` file path it provides for the current step.
11
+ *
12
+ * @param {Record<string, string | object | Array<unknown>>} fields Values are
13
+ * written as-is if already a string, JSON-stringified otherwise.
14
+ */
15
+ export function writeGithubActionsOutputs(fields) {
16
+ if (process.env.GITHUB_ACTIONS !== 'true') return;
17
+ const outputPath = process.env.GITHUB_OUTPUT;
18
+ if (!outputPath) return;
19
+
20
+ const lines = Object.entries(fields).map(([name, value]) => {
21
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
22
+ // A random delimiter, rather than a fixed one like "EOF", matters here:
23
+ // `value` can echo back content from a customer's own docs/OAS files
24
+ // (an error message quoting their spec, say), and a guessable delimiter
25
+ // could let that content prematurely close the heredoc and inject extra
26
+ // key=value pairs into the outputs file.
27
+ const delimiter = `ghadelimiter_${randomUUID()}`;
28
+ return `${name}<<${delimiter}\n${text}\n${delimiter}`;
29
+ });
30
+
31
+ fs.appendFileSync(outputPath, `${lines.join('\n')}\n`);
32
+ }
package/src/utils/lint.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { pathToFileURL, fileURLToPath } from 'node:url'
3
+ import * as componentsValidator from '../validators/components.js'
4
+ import * as contentValidator from '../validators/content.js'
5
+ import * as duplicatesValidator from '../validators/duplicates.js'
6
+ import * as frontmatterValidator from '../validators/frontmatter.js'
7
+ import * as linksValidator from '../validators/links.js'
8
+ import * as nestingValidator from '../validators/nesting.js'
9
+ import * as oasReferenceValidator from '../validators/oas-reference.js'
10
+ import * as oasSchemaValidator from '../validators/oas-schema.js'
11
+ import * as orderingValidator from '../validators/ordering.js'
12
+ import * as recipesValidator from '../validators/recipes.js'
4
13
 
5
14
  const TARGET_PATTERNS = [
6
15
  { dir: 'custom_blocks', ext: ['.mdx', '.md'] },
@@ -32,24 +41,30 @@ export function collectFiles(gitRoot) {
32
41
  return files.sort()
33
42
  }
34
43
 
44
+ // Every validator in src/validators/, imported statically (rather than
45
+ // discovered via a runtime directory scan) so this file — and anything that
46
+ // imports it, like the GitHub Action entrypoint in src/gha.js — can be
47
+ // bundled into a single file by a tool like ncc. Add a new validator's
48
+ // import above and its module here.
49
+ const ALL_VALIDATORS = [
50
+ componentsValidator,
51
+ contentValidator,
52
+ duplicatesValidator,
53
+ frontmatterValidator,
54
+ linksValidator,
55
+ nestingValidator,
56
+ oasReferenceValidator,
57
+ oasSchemaValidator,
58
+ orderingValidator,
59
+ recipesValidator,
60
+ ]
61
+
35
62
  /**
36
- * Auto-discover all validators from src/validators/.
37
63
  * Validators can export `validate()` for per-file checks and/or
38
64
  * `validateAll()` for cross-file checks (like ordering).
39
65
  */
40
- async function loadValidators() {
41
- const validatorsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'validators')
42
- const files = fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js'))
43
- const validators = []
44
-
45
- for (const file of files) {
46
- const mod = await import(pathToFileURL(path.join(validatorsDir, file)).href)
47
- if (mod.name && (mod.validate || mod.validateAll)) {
48
- validators.push(mod)
49
- }
50
- }
51
-
52
- return validators
66
+ function loadValidators() {
67
+ return ALL_VALIDATORS.filter((mod) => mod.name && (mod.validate || mod.validateAll))
53
68
  }
54
69
 
55
70
  /**
@@ -1,7 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import matter from 'gray-matter';
4
- import { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js';
4
+ import {
5
+ findOasFiles,
6
+ extractOperations,
7
+ collectExistingPages,
8
+ syncOas,
9
+ operationKey,
10
+ findIgnoredInternalExtensions,
11
+ } from '../commands/oas-sync.js';
5
12
 
6
13
  export const name = 'oas-reference';
7
14
 
@@ -16,6 +23,17 @@ export function validateAll(files, gitRoot, { fix } = {}) {
16
23
  const oasMap = new Map();
17
24
  for (const { filename, spec } of oasFiles) {
18
25
  oasMap.set(filename, { spec, ops: extractOperations(spec) });
26
+
27
+ // Check: `x-readme.internal`, which ReadMe ignores for page visibility.
28
+ for (const location of findIgnoredInternalExtensions(spec)) {
29
+ results.push({
30
+ file: `reference/${filename}`,
31
+ rule: name,
32
+ severity: 'warning',
33
+ message: `"x-readme.internal" is ignored by ReadMe (${location}); use "x-internal" instead to hide pages`,
34
+ fixable: false,
35
+ });
36
+ }
19
37
  }
20
38
 
21
39
  // Collect all reference pages with api frontmatter.
@@ -87,8 +105,11 @@ export function validateAll(files, gitRoot, { fix } = {}) {
87
105
  }
88
106
  }
89
107
 
90
- // Apply fixes by running the full sync.
91
- if (fix && results.length > 0) {
108
+ // Apply fixes by running the full sync — but only when something reported
109
+ // is actually fixable. The sync adds, deletes, moves, hides and reorders
110
+ // reference files, which is far too much to do on the strength of an
111
+ // unfixable warning (`x-readme.internal`, a page pointing at a missing spec).
112
+ if (fix && results.some((r) => r.fixable)) {
92
113
  const syncResults = syncOas(gitRoot);
93
114
  if (syncResults) {
94
115
  for (const r of results) {