@readme/cli 0.1.0 → 0.2.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.
Files changed (53) hide show
  1. package/bin/readme.js +1 -1
  2. package/package.json +33 -24
  3. package/src/bootstrap.js +9 -13
  4. package/src/cli.js +17 -11
  5. package/src/commands/components.js +35 -18
  6. package/src/commands/dev.js +12 -8
  7. package/src/commands/eyes.js +12 -8
  8. package/src/commands/import.js +2190 -2048
  9. package/src/commands/lint.js +33 -29
  10. package/src/commands/oas-bundle.js +6 -2
  11. package/src/commands/oas-sync.js +96 -93
  12. package/src/commands/oas-validate.js +10 -7
  13. package/src/commands/pretty.js +177 -164
  14. package/src/commands/setup.js +24 -26
  15. package/src/commands/versions.js +6 -2
  16. package/src/dev/app/[...slug]/page.js +1 -0
  17. package/src/dev/app/layout.js +18 -11
  18. package/src/dev/app/page.js +1 -0
  19. package/src/dev/lib/docs.js +27 -19
  20. package/src/dev/next.config.mjs +2 -2
  21. package/src/gha.js +7 -7
  22. package/src/prompts/index.js +16 -6
  23. package/src/utils/eyes.js +143 -51
  24. package/src/utils/git.js +3 -3
  25. package/src/utils/lint.js +39 -38
  26. package/src/utils/llms.js +94 -91
  27. package/src/utils/markdown.js +256 -0
  28. package/src/utils/marketplace.js +12 -5
  29. package/src/utils/multi-spinner.js +5 -7
  30. package/src/utils/promise-pool.js +7 -5
  31. package/src/utils/reporter.js +88 -91
  32. package/src/utils/setup-templates.js +12 -6
  33. package/src/utils/styles.js +2 -1
  34. package/src/utils/tamagotchi.js +257 -91
  35. package/src/utils/tips.js +18 -9
  36. package/src/utils/url-segs.js +46 -41
  37. package/src/validators/components.js +55 -63
  38. package/src/validators/content.js +5 -10
  39. package/src/validators/duplicates.js +6 -2
  40. package/src/validators/frontmatter.js +19 -21
  41. package/src/validators/links.js +4 -8
  42. package/src/validators/oas-reference.js +7 -14
  43. package/src/validators/oas-schema.js +2 -0
  44. package/src/validators/ordering.js +5 -5
  45. package/src/validators/recipes.js +4 -12
  46. package/src/commands/import-oas.test.js +0 -277
  47. package/src/commands/import.classify.test.js +0 -172
  48. package/src/commands/import.landing.test.js +0 -217
  49. package/src/commands/import.slug-planner.test.js +0 -86
  50. package/src/commands/import.staging.test.js +0 -230
  51. package/src/commands/import.test.js +0 -382
  52. package/src/utils/llms.test.js +0 -302
  53. package/src/utils/url-segs.test.js +0 -239
@@ -1,20 +1,20 @@
1
- import { collectFiles, runValidators } from '../utils/lint.js'
2
- import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
3
- import { printHeader, isAgenticCli } from '../utils/eyes.js'
4
- import * as styles from '../utils/styles.js'
5
- import { writeGithubActionsOutputs } from '../utils/gha-output.js'
6
- import pkg from '../../package.json' with { type: 'json' }
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ import { printHeader, isAgenticCli } from '../utils/eyes.js';
3
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js';
4
+ import { collectFiles, runValidators } from '../utils/lint.js';
5
+ import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js';
6
+ import * as styles from '../utils/styles.js';
7
7
 
8
- export const command = 'lint'
9
- export const order = 1
10
- export const aliases = ['validate']
11
- export const category = 'Linting'
12
- export const description = 'Lint and validate your ReadMe docs'
8
+ export const command = 'lint';
9
+ export const order = 1;
10
+ export const aliases = ['validate'];
11
+ export const category = 'Linting';
12
+ export const description = 'Lint and validate your ReadMe docs';
13
13
 
14
14
  export function args(cmd) {
15
- cmd.option('--json', 'Output results as JSON (for CI and automation)')
16
- cmd.option('--github', 'Output a GitHub PR comment body as markdown')
17
- cmd.option('--fix', 'Automatically fix issues where possible')
15
+ cmd.option('--json', 'Output results as JSON (for CI and automation)');
16
+ cmd.option('--github', 'Output a GitHub PR comment body as markdown');
17
+ cmd.option('--fix', 'Automatically fix issues where possible');
18
18
  }
19
19
 
20
20
  /**
@@ -31,37 +31,41 @@ export function args(cmd) {
31
31
  * @returns {Promise<{ gitRoot: string, files: string[], results: object[], hasErrors: boolean }>}
32
32
  */
33
33
  export async function lint({ cwd, fix = false, nonInteractive = false, onFile, onBeforeCrossFile } = {}) {
34
- const gitRoot = cwd || process.cwd()
35
- const files = collectFiles(gitRoot)
36
- const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive })
37
- const hasErrors = results.some((r) => r.severity !== 'warning')
38
- return { gitRoot, files, results, hasErrors }
34
+ const gitRoot = cwd || process.cwd();
35
+ const files = collectFiles(gitRoot);
36
+ const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive });
37
+ const hasErrors = results.some(r => r.severity !== 'warning');
38
+ return { gitRoot, files, results, hasErrors };
39
39
  }
40
40
 
41
41
  export async function run(options, _cmd, ctx) {
42
- const { gitRoot } = ctx
42
+ const { gitRoot } = ctx;
43
43
 
44
44
  if (!options.json && !options.github && !isAgenticCli()) {
45
- printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
46
- console.log()
45
+ printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
46
+ console.log();
47
47
  }
48
48
 
49
- const reporter = options.github ? createGithubReporter() : options.json ? createJsonReporter() : createHumanReporter()
49
+ const reporter = options.github
50
+ ? createGithubReporter()
51
+ : options.json
52
+ ? createJsonReporter()
53
+ : createHumanReporter();
50
54
 
51
55
  const { files, results, hasErrors } = await lint({
52
56
  cwd: gitRoot,
53
57
  fix: options.fix,
54
- onFile: (f) => reporter.onFile(f),
58
+ onFile: f => reporter.onFile(f),
55
59
  onBeforeCrossFile: () => reporter.pause(),
56
- })
60
+ });
57
61
 
58
- reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
62
+ reporter.finish(files.length, results, files, { fix: options.fix, gitRoot });
59
63
 
60
- writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results })
64
+ writeGithubActionsOutputs({ 'has-errors': String(hasErrors), results });
61
65
 
62
66
  if (hasErrors) {
63
67
  // Wait for stdout to flush (important when piped to a file), then exit
64
- await new Promise((resolve) => process.stdout.write('', resolve))
65
- process.exit(1)
68
+ await new Promise(resolve => process.stdout.write('', resolve));
69
+ process.exit(1);
66
70
  }
67
71
  }
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
4
5
  import { bundle } from '@readme/openapi-parser';
6
+
5
7
  import * as styles from '../utils/styles.js';
6
8
 
7
9
  const require = createRequire(import.meta.url);
@@ -61,7 +63,9 @@ export async function run(specArg, options) {
61
63
  const opCount = countOperations(spec);
62
64
  const rel = path.relative(process.cwd(), outPath);
63
65
  const shown = rel && !rel.startsWith('..') ? rel : outPath;
64
- styles.ok(`Bundled ${styles.bold(spec.info?.title || specArg)} (${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'}) → ${shown}`);
66
+ styles.ok(
67
+ `Bundled ${styles.bold(spec.info?.title || specArg)} (${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'}) → ${shown}`,
68
+ );
65
69
  return;
66
70
  }
67
71
 
@@ -1,12 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import matter from 'gray-matter';
5
- import * as styles from '../utils/styles.js';
6
- import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
3
 
8
- const require = createRequire(import.meta.url);
9
- const yaml = require('js-yaml');
4
+ import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
5
+
6
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
+ import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
8
+ import * as styles from '../utils/styles.js';
10
9
 
11
10
  export const command = 'oas:sync';
12
11
  export const order = 2;
@@ -15,6 +14,9 @@ export const description = 'Sync reference pages with OpenAPI specs';
15
14
 
16
15
  const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
17
16
 
17
+ // js-yaml 5's CORE_SCHEMA omits YAML merge keys (`<<: *anchor`). OpenAPI specs commonly
18
+ // reuse path items via merges — keep that working when reading YAML specs.
19
+ const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
18
20
 
19
21
  /**
20
22
  * Find OAS files at the root of reference/ (JSON or YAML).
@@ -30,7 +32,7 @@ export function findOasFiles(refDir) {
30
32
 
31
33
  try {
32
34
  const raw = fs.readFileSync(filePath, 'utf-8');
33
- const parsed = entry.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
35
+ const parsed = entry.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
34
36
 
35
37
  if (parsed && (parsed.openapi || parsed.swagger)) {
36
38
  oasFiles.push({ filename: entry, spec: parsed });
@@ -139,7 +141,7 @@ function resolveXInternal(operation, spec) {
139
141
  * `webhook POST newPet`) for lint to warn about.
140
142
  */
141
143
  export function findIgnoredInternalExtensions(spec) {
142
- const hasInternal = (obj) => {
144
+ const hasInternal = obj => {
143
145
  const xReadme = obj?.['x-readme'];
144
146
  return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme;
145
147
  };
@@ -147,7 +149,10 @@ export function findIgnoredInternalExtensions(spec) {
147
149
  const locations = [];
148
150
  if (hasInternal(spec)) locations.push('root');
149
151
 
150
- for (const [entries, isWebhook] of [[spec?.paths, false], [spec?.webhooks, true]]) {
152
+ for (const [entries, isWebhook] of [
153
+ [spec?.paths, false],
154
+ [spec?.webhooks, true],
155
+ ]) {
151
156
  for (const [name, rawItem] of Object.entries(entries || {})) {
152
157
  for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) {
153
158
  if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue;
@@ -199,7 +204,7 @@ export function extractOperations(spec) {
199
204
  summary: operation.summary || null,
200
205
  description: operation.description || null,
201
206
  // The platform groups by the first *non-empty* tag.
202
- tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null,
207
+ tag: (Array.isArray(operation.tags) && operation.tags.find(t => t)) || null,
203
208
  path: pathStr,
204
209
  isWebhook,
205
210
  xInternal: resolveXInternal(operation, spec),
@@ -228,19 +233,15 @@ export function collectExistingPages(refDir) {
228
233
  if (stat.isDirectory()) {
229
234
  walk(full);
230
235
  } else if (entry.endsWith('.md')) {
231
- try {
232
- const content = fs.readFileSync(full, 'utf-8');
233
- const { data } = matter(content);
234
- if (data.api && data.api.file && data.api.operationId) {
235
- pages.push({
236
- filePath: full,
237
- relativePath: path.relative(refDir, full),
238
- data,
239
- content,
240
- });
241
- }
242
- } catch {
243
- // Skip unparseable files.
236
+ const content = fs.readFileSync(full, 'utf-8');
237
+ const { data } = parseFrontmatter(content);
238
+ if (data.api && data.api.file && data.api.operationId) {
239
+ pages.push({
240
+ filePath: full,
241
+ relativePath: path.relative(refDir, full),
242
+ data,
243
+ content,
244
+ });
244
245
  }
245
246
  }
246
247
  }
@@ -259,13 +260,18 @@ function yamlSafeSlug(slug) {
259
260
  function parseOrderYaml(content) {
260
261
  return content
261
262
  .split('\n')
262
- .map((line) => line.trim())
263
- .filter((line) => line.startsWith('- '))
264
- .map((line) => line.slice(2).trim().replace(/^["'](.+)["']$/, '$1'));
263
+ .map(line => line.trim())
264
+ .filter(line => line.startsWith('- '))
265
+ .map(line =>
266
+ line
267
+ .slice(2)
268
+ .trim()
269
+ .replace(/^["'](.+)["']$/, '$1'),
270
+ );
265
271
  }
266
272
 
267
273
  function writeOrderYaml(filePath, slugs) {
268
- const content = slugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n';
274
+ const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
269
275
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
270
276
  fs.writeFileSync(filePath, content);
271
277
  }
@@ -286,7 +292,7 @@ function addToOrder(orderPath, slug) {
286
292
  function removeFromOrder(orderPath, slug) {
287
293
  if (!fs.existsSync(orderPath)) return;
288
294
  const content = fs.readFileSync(orderPath, 'utf-8');
289
- const slugs = parseOrderYaml(content).filter((s) => s !== slug);
295
+ const slugs = parseOrderYaml(content).filter(s => s !== slug);
290
296
  if (slugs.length > 0) {
291
297
  writeOrderYaml(orderPath, slugs);
292
298
  } else {
@@ -306,19 +312,7 @@ function safeSegment(value, fallback) {
306
312
 
307
313
  function isWithin(baseDir, target) {
308
314
  const rel = path.relative(baseDir, target);
309
- return (
310
- rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
311
- );
312
- }
313
-
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+$/, '');
315
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
322
316
  }
323
317
 
324
318
  function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
@@ -339,7 +333,7 @@ function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false
339
333
  hidden,
340
334
  };
341
335
 
342
- return stringifyFrontmatter(frontmatter);
336
+ return toFrontmatter(frontmatter);
343
337
  }
344
338
 
345
339
  /**
@@ -354,7 +348,7 @@ function buildTagIndexContent(title, description) {
354
348
  // As with operation pages, upload always stamps hidden: false on new pages.
355
349
  frontmatter.hidden = false;
356
350
 
357
- return stringifyFrontmatter(frontmatter);
351
+ return toFrontmatter(frontmatter);
358
352
  }
359
353
 
360
354
  /**
@@ -493,17 +487,16 @@ function reserveSlug(takenSlugs, base) {
493
487
  /**
494
488
  * Rewrite a page's frontmatter in place, keeping its body. `mutate` receives
495
489
  * 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.
490
+ * changed.
498
491
  */
499
492
  function updateFrontmatter(filePath, mutate) {
500
493
  const content = fs.readFileSync(filePath, 'utf-8');
501
- const parsed = matter(content);
494
+ const parsed = parseFrontmatter(content);
495
+ // Refuse to rewrite from empty `data` when YAML failed to parse.
496
+ if (frontmatterWriteError(parsed)) return false;
502
497
  const data = structuredClone(parsed.data);
503
498
  mutate(data);
504
- const next = parsed.content.trim()
505
- ? matter.stringify(parsed.content, data)
506
- : stringifyFrontmatter(data);
499
+ const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
507
500
  if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
508
501
  fs.writeFileSync(filePath, next);
509
502
  return true;
@@ -527,7 +520,7 @@ export function applyOASOrder(currentOrder, orderedSlugs) {
527
520
  const order = [...new Set(currentOrder)];
528
521
  if (!order.length) return desired;
529
522
 
530
- const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter((i) => i > -1);
523
+ const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(i => i > -1);
531
524
  if (!slots.length) return [...order, ...desired];
532
525
 
533
526
  const remaining = [...desired];
@@ -561,21 +554,17 @@ const INDEX_FILES = ['index.md', 'index.mdx', 'index.html'];
561
554
  * deleting it. A stale page can be removed by hand; a deleted edit is gone.
562
555
  */
563
556
  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;
557
+ const { data, content, error } = parseFrontmatter(fs.readFileSync(indexPath, 'utf-8'));
558
+ if (error || content.trim()) return false;
572
559
  if (typeof data.title !== 'string' || !('hidden' in data)) return false;
573
- if (Object.keys(data).some((k) => !['title', 'hidden', 'excerpt'].includes(k))) return false;
560
+ if (Object.keys(data).some(k => !['title', 'hidden', 'excerpt'].includes(k))) return false;
574
561
  if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false;
575
562
 
576
563
  const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath)));
577
564
  const titleSlug = slugifyFolder(data.title);
578
- return dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug);
565
+ return (
566
+ dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug)
567
+ );
579
568
  }
580
569
 
581
570
  /**
@@ -592,12 +581,12 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
592
581
  } catch {
593
582
  return;
594
583
  }
595
- const others = entries.filter((e) => !INDEX_FILES.includes(e) && e !== '_order.yaml');
584
+ const others = entries.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
596
585
  if (others.length) return;
597
586
 
598
587
  const parentDir = path.dirname(dir);
599
588
  const slug = path.basename(dir);
600
- const indexFile = entries.find((e) => INDEX_FILES.includes(e));
589
+ const indexFile = entries.find(e => INDEX_FILES.includes(e));
601
590
 
602
591
  if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
603
592
  fs.rmSync(dir, { recursive: true, force: true });
@@ -625,10 +614,7 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
625
614
  */
626
615
  function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
627
616
  const specOps = extractOperations(spec);
628
- const infoTitle = safeSegment(
629
- spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
630
- 'api',
631
- );
617
+ const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
632
618
 
633
619
  // Hyphen vs. space in a group's folder name is not a meaningful difference
634
620
  // — "shipping-labels" and "shipping labels" are the same folder to a
@@ -639,16 +625,11 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
639
625
  const apiDir = path.join(refDir, infoTitle);
640
626
  const foldersBySlug = existingFoldersBySlug(apiDir);
641
627
 
642
- const existingPages = collectExistingPages(refDir).filter(
643
- (p) => p.data.api.file === oasFilename,
644
- );
628
+ const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
645
629
 
646
630
  const pagesByOpId = new Map();
647
631
  for (const page of existingPages) {
648
- pagesByOpId.set(
649
- operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
650
- page,
651
- );
632
+ pagesByOpId.set(operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }), page);
652
633
  }
653
634
 
654
635
  const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] };
@@ -661,9 +642,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
661
642
  // Tag descriptions from the spec's top-level `tags` array, used for the
662
643
  // per-tag category landing page (index.md).
663
644
  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]),
645
+ (Array.isArray(spec.tags) ? spec.tags : []).filter(t => t && t.name).map(t => [t.name, t.description || null]),
667
646
  );
668
647
  // Every description the spec supplies, regardless of which tag: what an
669
648
  // emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`).
@@ -714,13 +693,34 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
714
693
  // listed in `tags`) keeps its natural encounter order, appended after every
715
694
  // declared tag.
716
695
  const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
717
- .filter((t) => t && t.name)
718
- .map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
696
+ .filter(t => t && t.name)
697
+ .map(t => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
719
698
  const orderedFolders = [
720
- ...declaredOrder.filter((folder) => groupsByFolder.has(folder)),
721
- ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)),
699
+ ...declaredOrder.filter(folder => groupsByFolder.has(folder)),
700
+ ...[...groupsByFolder.keys()].filter(folder => !declaredOrder.includes(folder)),
722
701
  ];
723
702
 
703
+ // Groups that will hold at least one of this spec's pages after this run:
704
+ // a new page is created in its folder, an existing page already lives
705
+ // anywhere under it (a direct child, or nested in a hand-made subfolder),
706
+ // or `apply-tag-changes` will move one in (see the move pass below). A group
707
+ // whose pages were all moved somewhere else (another category, or a folder
708
+ // spelled differently) gets no category page — creating one would leave an
709
+ // empty index.md with no children.
710
+ const populatedFolders = new Set();
711
+ for (const [key, op] of specOps) {
712
+ const { folder } = operationGroup(op);
713
+ const page = pagesByOpId.get(key);
714
+ const tagDir = path.join(apiDir, foldersBySlug.get(folder) || folder);
715
+ if (
716
+ !page ||
717
+ isWithin(tagDir, page.filePath) ||
718
+ (applyTagChanges && isWithin(apiDir, page.filePath) && path.basename(page.filePath) !== 'index.md')
719
+ ) {
720
+ populatedFolders.add(folder);
721
+ }
722
+ }
723
+
724
724
  const groupDirs = new Map();
725
725
  const createdIndexes = new Set();
726
726
  for (const folder of orderedFolders) {
@@ -728,9 +728,11 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
728
728
  const actualFolder = foldersBySlug.get(folder) || folder;
729
729
  const pageDir = path.join(refDir, infoTitle, actualFolder);
730
730
  if (!isWithin(refDir, pageDir)) continue;
731
- groupDirs.set(folder, pageDir);
732
731
 
733
732
  const indexPath = path.join(pageDir, 'index.md');
733
+ if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
734
+ groupDirs.set(folder, pageDir);
735
+
734
736
  if (!fs.existsSync(indexPath)) {
735
737
  // Never overwrite an existing index.md — it may be a hand-written category.
736
738
  fs.mkdirSync(pageDir, { recursive: true });
@@ -745,7 +747,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
745
747
  // With `apply-tag-changes`, the spec owns the category page's title and
746
748
  // excerpt too (a tag with no description clears the excerpt). Its body
747
749
  // and any other frontmatter are the user's and are kept.
748
- const updated = updateFrontmatter(indexPath, (data) => {
750
+ const updated = updateFrontmatter(indexPath, data => {
749
751
  data.title = title;
750
752
  if (description) data.excerpt = description;
751
753
  else delete data.excerpt;
@@ -801,7 +803,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
801
803
  // its own `hidden` — removing the extension never unhides a page.
802
804
  if (op.xInternal.present) {
803
805
  const hidden = Boolean(op.xInternal.value);
804
- const updated = updateFrontmatter(filePath, (data) => {
806
+ const updated = updateFrontmatter(filePath, data => {
805
807
  data.hidden = hidden;
806
808
  });
807
809
  if (updated) changes.updated.push(path.relative(refDir, filePath));
@@ -861,9 +863,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
861
863
  const indexPath = path.join(dir, 'index.md');
862
864
  if (!fs.existsSync(indexPath)) continue;
863
865
  const isNew = createdIndexes.has(indexPath);
864
- const allHidden = children.every((x) => x.present && (isNew ? Boolean(x.value) : x.value === true));
866
+ const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
865
867
  if (!allHidden) continue;
866
- const updated = updateFrontmatter(indexPath, (data) => {
868
+ const updated = updateFrontmatter(indexPath, data => {
867
869
  data.hidden = true;
868
870
  });
869
871
  if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
@@ -925,7 +927,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
925
927
  * Returns null if there's no reference/ dir or no specs.
926
928
  */
927
929
  export function syncOas(input) {
928
- const gitRoot = typeof input === 'string' ? input : (input?.cwd || process.cwd());
930
+ const gitRoot = typeof input === 'string' ? input : input?.cwd || process.cwd();
929
931
  const refDir = path.join(gitRoot, 'reference');
930
932
  if (!fs.existsSync(refDir)) return null;
931
933
 
@@ -977,7 +979,9 @@ export function printSyncResults(results) {
977
979
 
978
980
  const dot = hasChanges ? styles.warn('●') : styles.success('●');
979
981
  console.log();
980
- console.log(` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`);
982
+ console.log(
983
+ ` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`,
984
+ );
981
985
 
982
986
  if (!hasChanges) {
983
987
  continue;
@@ -996,9 +1000,7 @@ export function printSyncResults(results) {
996
1000
  console.log(` ${styles.warn('~')} Updated ${file}`);
997
1001
  }
998
1002
  for (const { path: file, operationId } of changes.skipped) {
999
- console.log(
1000
- ` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
1001
- );
1003
+ console.log(` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`);
1002
1004
  skipped.push({ filename, path: file, operationId });
1003
1005
  }
1004
1006
 
@@ -1046,8 +1048,7 @@ export async function run(_options, _cmd, ctx) {
1046
1048
  return;
1047
1049
  }
1048
1050
 
1049
- const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } =
1050
- printSyncResults(results);
1051
+ const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = printSyncResults(results);
1051
1052
 
1052
1053
  console.log();
1053
1054
  const total = totalAdded + totalDeleted + totalMoved + totalUpdated;
@@ -1064,7 +1065,9 @@ export async function run(_options, _cmd, ctx) {
1064
1065
  // to whoever wraps this command in CI to notice and fail on it themselves.
1065
1066
  if (totalSkipped > 0) {
1066
1067
  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.`);
1068
+ styles.error(
1069
+ `${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`,
1070
+ );
1068
1071
  } else if (total === 0) {
1069
1072
  styles.ok('Reference pages are already in sync.');
1070
1073
  } else {
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+
3
4
  import OASNormalize from 'oas-normalize';
4
- import { findOasFiles, extractOperations } from './oas-sync.js';
5
- import * as styles from '../utils/styles.js';
5
+
6
6
  import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
+ import * as styles from '../utils/styles.js';
8
+
9
+ import { findOasFiles, extractOperations } from './oas-sync.js';
7
10
 
8
11
  export const command = 'oas:validate';
9
12
  export const order = 3;
@@ -114,7 +117,7 @@ export async function validateOas({ cwd, dereference = false } = {}) {
114
117
  continue;
115
118
  }
116
119
 
117
- const errors = normalizerResult.valid ? [] : (normalizerResult.errors || []);
120
+ const errors = normalizerResult.valid ? [] : normalizerResult.errors || [];
118
121
  const warnings = normalizerResult.warnings || [];
119
122
 
120
123
  if (dereference) {
@@ -158,9 +161,7 @@ export async function validateOasFiles(gitRoot, { dereference = false } = {}) {
158
161
 
159
162
  for (const r of summary.results) {
160
163
  const meta = `${r.filename} · ${r.version} · ${r.opCount} ${r.opCount === 1 ? 'endpoint' : 'endpoints'}`;
161
- const dot = r.errors.length > 0
162
- ? styles.err('●')
163
- : r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
164
+ const dot = r.errors.length > 0 ? styles.err('●') : r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
164
165
 
165
166
  console.log();
166
167
  console.log(` ${dot} ${styles.bold(r.title)} ${styles.dim(`(${meta})`)}`);
@@ -224,7 +225,9 @@ export async function run(options, _cmd, ctx) {
224
225
  styles.error(`${parts.join(' and ')} across ${fileCount} ${fileCount === 1 ? 'spec' : 'specs'}.`);
225
226
  process.exit(1);
226
227
  } else if (totalWarnings > 0) {
227
- styles.warning(`${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`);
228
+ styles.warning(
229
+ `${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`,
230
+ );
228
231
  } else {
229
232
  styles.ok(`${fileCount} ${fileCount === 1 ? 'spec' : 'specs'} validated — all good!`);
230
233
  }