@readme/cli 0.0.27 → 0.0.30

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 (123) hide show
  1. package/package.json +5 -5
  2. package/src/cli.js +3 -3
  3. package/src/commands/import-oas.test.js +277 -0
  4. package/src/commands/import.classify.test.js +172 -0
  5. package/src/commands/import.js +2848 -443
  6. package/src/commands/import.landing.test.js +217 -0
  7. package/src/commands/import.slug-planner.test.js +86 -0
  8. package/src/commands/import.staging.test.js +158 -0
  9. package/src/commands/import.test.js +382 -0
  10. package/src/commands/oas-sync.js +362 -92
  11. package/src/prompts/index.js +101 -13
  12. package/src/utils/lint.js +2 -2
  13. package/src/utils/llms.js +224 -0
  14. package/src/utils/llms.test.js +302 -0
  15. package/src/utils/reporter.js +2 -1
  16. package/src/utils/slug-for-entry.js +16 -0
  17. package/src/utils/url-segs.js +124 -0
  18. package/src/utils/url-segs.test.js +239 -0
  19. package/src/validators/components.js +11 -7
  20. package/src/validators/duplicates.js +8 -8
  21. package/src/validators/frontmatter.js +11 -0
  22. package/src/validators/links.js +2 -8
  23. package/src/validators/oas-reference.js +13 -38
  24. package/src/validators/ordering.js +169 -83
  25. package/vendor/TOOLS.md +1 -1
  26. package/src/dev/.next/app-build-manifest.json +0 -20
  27. package/src/dev/.next/build-manifest.json +0 -31
  28. package/src/dev/.next/cache/.rscinfo +0 -1
  29. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  30. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  40. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  43. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  50. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  69. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  70. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  71. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  72. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  73. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  74. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  75. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  76. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  77. package/src/dev/.next/package.json +0 -1
  78. package/src/dev/.next/prerender-manifest.json +0 -11
  79. package/src/dev/.next/react-loadable-manifest.json +0 -1
  80. package/src/dev/.next/routes-manifest.json +0 -1
  81. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  82. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  83. package/src/dev/.next/server/app/page.js +0 -349
  84. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  85. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  86. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  87. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  88. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  89. package/src/dev/.next/server/middleware-manifest.json +0 -32
  90. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  91. package/src/dev/.next/server/middleware.js +0 -1113
  92. package/src/dev/.next/server/next-font-manifest.js +0 -1
  93. package/src/dev/.next/server/next-font-manifest.json +0 -1
  94. package/src/dev/.next/server/pages-manifest.json +0 -5
  95. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  96. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  97. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  98. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  99. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  100. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  101. package/src/dev/.next/server/webpack-runtime.js +0 -209
  102. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  103. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  104. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  105. package/src/dev/.next/static/chunks/app/page.js +0 -28
  106. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  107. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  108. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  109. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  110. package/src/dev/.next/static/css/app/layout.css +0 -559
  111. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  112. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  113. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  114. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  115. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  116. package/src/dev/.next/trace +0 -21
  117. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  118. package/src/dev/.next/types/app/layout.ts +0 -84
  119. package/src/dev/.next/types/app/page.ts +0 -84
  120. package/src/dev/.next/types/cache-life.d.ts +0 -141
  121. package/src/dev/.next/types/package.json +0 -1
  122. package/src/dev/.next/types/routes.d.ts +0 -55
  123. package/src/validators/numbering.js +0 -134
@@ -14,12 +14,6 @@ export const description = 'Sync reference pages with OpenAPI specs';
14
14
 
15
15
  const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
16
16
 
17
- /**
18
- * Check if this is a ReadMeConfig spec (internal ReadMe pages — skip title/excerpt updates).
19
- */
20
- function isReadMeConfig(spec) {
21
- return spec.info?.title === 'ReadMeConfig';
22
- }
23
17
 
24
18
  /**
25
19
  * Find OAS files at the root of reference/ (JSON or YAML).
@@ -62,29 +56,104 @@ function generateOperationId(method, pathStr) {
62
56
  }
63
57
 
64
58
  /**
65
- * Extract operations from an OAS spec.
66
- * Returns a Map of operationId -> { summary, description, tag, operationId }.
67
- * For operations without an operationId, a synthetic one is generated from the method and path.
59
+ * Identity key for an operation record (or an existing page's frontmatter),
60
+ * used everywhere operations/pages are looked up by operationId. `paths` and
61
+ * `webhooks` are separate namespaces in an OAS document, but both can have
62
+ * operationId omitted, so their synthetic `<method>_<name>` ids can
63
+ * legitimately collide (e.g. `POST /orders` and webhook `POST orders` both
64
+ * synthesize to `post_orders`) — the isWebhook flag disambiguates them so
65
+ * neither silently overwrites the other in an operationId-only Map.
66
+ */
67
+ export function operationKey({ operationId, isWebhook }) {
68
+ return `${isWebhook ? 'webhook' : 'path'}:${operationId}`;
69
+ }
70
+
71
+ /**
72
+ * Resolve a `paths`/`webhooks` entry that's a Reference Object (OAS 3.1,
73
+ * `{ $ref: '#/components/pathItems/Name' }`) against the spec's own
74
+ * `components.pathItems`, following chained refs (a pathItem that is itself
75
+ * a $ref to another) until a literal Path Item is reached. Only same-document
76
+ * refs in that exact form are supported; anything else (external files,
77
+ * other pointer shapes, an unresolvable name, or a cycle) is left unresolved
78
+ * and quietly skipped by the caller, same as before this existed.
79
+ */
80
+ function resolveLocalPathItemRef(entry, spec) {
81
+ const seen = new Set();
82
+ let current = entry;
83
+ // Sibling fields (e.g. an inline operation) alongside a $ref are explicitly
84
+ // allowed in an OAS 3.1 Path Item Object — accumulate them from every hop
85
+ // in the chain so they aren't discarded once $ref is followed. A field
86
+ // declared at an outer/earlier hop wins over the same field found deeper
87
+ // in the chain (OAS itself leaves this "undefined" when both define it).
88
+ let overrides = {};
89
+ const finish = () => ({ ...current, ...overrides });
90
+
91
+ while (current && typeof current.$ref === 'string') {
92
+ const { $ref, ...siblings } = current;
93
+ overrides = { ...siblings, ...overrides };
94
+
95
+ if (seen.has(current.$ref)) return finish();
96
+ seen.add(current.$ref);
97
+
98
+ const match = current.$ref.match(/^#\/components\/pathItems\/(.+)$/);
99
+ if (!match) return finish();
100
+
101
+ let name;
102
+ try {
103
+ name = decodeURIComponent(match[1]).replace(/~1/g, '/').replace(/~0/g, '~');
104
+ } catch {
105
+ // Malformed percent-escape — leave unresolved rather than throwing and
106
+ // aborting the whole sync/lint run over one bad $ref.
107
+ return finish();
108
+ }
109
+ const resolved = spec.components?.pathItems?.[name];
110
+ if (!resolved) return finish();
111
+
112
+ current = resolved;
113
+ }
114
+
115
+ return finish();
116
+ }
117
+
118
+ /**
119
+ * Extract operations from an OAS spec's `paths`, plus its OAS 3.1 `webhooks`
120
+ * (callouts the API itself makes to a client-registered URL, not endpoints the
121
+ * API exposes — a separate top-level sibling of `paths` with the same
122
+ * Operation Object shape). The platform pages a webhook the same way it pages
123
+ * a path operation: a synthetic `post_<name>` operationId when none is given,
124
+ * grouped by its own tag or, absent one, its own category keyed by its raw
125
+ * name — never merged with `paths` operations of the same name.
126
+ * Returns a Map keyed by `operationKey()` -> { summary, description, tag,
127
+ * path, operationId, isWebhook }. For operations without an operationId, a
128
+ * synthetic one is generated from the method and path (or webhook name).
68
129
  */
69
130
  export function extractOperations(spec) {
70
131
  const ops = new Map();
71
- const paths = spec.paths || {};
72
132
 
73
- for (const [pathStr, methods] of Object.entries(paths)) {
74
- for (const [method, operation] of Object.entries(methods)) {
75
- if (!HTTP_METHODS.has(method)) continue;
133
+ function collect(entries, isWebhook) {
134
+ for (const [pathStr, rawItem] of Object.entries(entries)) {
135
+ const methods = resolveLocalPathItemRef(rawItem, spec);
136
+
137
+ for (const [method, operation] of Object.entries(methods)) {
138
+ if (!HTTP_METHODS.has(method)) continue;
76
139
 
77
- const operationId = operation.operationId || generateOperationId(method, pathStr);
140
+ const operationId = operation.operationId || generateOperationId(method, pathStr);
78
141
 
79
- ops.set(operationId, {
80
- operationId,
81
- summary: operation.summary || null,
82
- description: operation.description || null,
83
- tag: (operation.tags && operation.tags[0]) || null,
84
- });
142
+ ops.set(operationKey({ operationId, isWebhook }), {
143
+ operationId,
144
+ summary: operation.summary || null,
145
+ description: operation.description || null,
146
+ tag: (operation.tags && operation.tags[0]) || null,
147
+ path: pathStr,
148
+ isWebhook,
149
+ });
150
+ }
85
151
  }
86
152
  }
87
153
 
154
+ collect(spec.paths || {}, false);
155
+ collect(spec.webhooks || {}, true);
156
+
88
157
  return ops;
89
158
  }
90
159
 
@@ -168,29 +237,178 @@ function removeFromOrder(orderPath, slug) {
168
237
  }
169
238
  }
170
239
 
171
- function buildPageContent({ oasFilename, operationId, summary, description }) {
240
+ /**
241
+ * Spec-derived values (info.title, tags, operationIds) become directory and
242
+ * file names. Collapse path separators and dot-only names into a single safe
243
+ * segment so a crafted spec can't write outside reference/.
244
+ */
245
+ function safeSegment(value, fallback) {
246
+ const segment = String(value).replace(/[/\\]/g, '-').trim();
247
+ return !segment || segment === '.' || segment === '..' ? fallback : segment;
248
+ }
249
+
250
+ function isWithin(baseDir, target) {
251
+ const rel = path.relative(baseDir, target);
252
+ return (
253
+ rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Render a frontmatter-only page. `matter.stringify` always appends a blank
259
+ * body after the closing fence (even for an empty body); the platform's own
260
+ * generated pages end immediately after the fence with no trailing newline,
261
+ * so trim it to match.
262
+ */
263
+ function stringifyFrontmatter(frontmatter) {
264
+ return matter.stringify('', frontmatter).replace(/\n+$/, '');
265
+ }
266
+
267
+ function buildPageContent({ oasFilename, operationId, isWebhook }) {
172
268
  const frontmatter = {
173
- title: summary || operationId,
174
269
  api: {
175
270
  file: oasFilename,
176
271
  operationId,
272
+ // Marks the page as a webhook (the API calling out to the client)
273
+ // rather than a path operation (the client calling the API), matching
274
+ // what the platform stamps on a page generated from `webhooks`.
275
+ ...(isWebhook ? { webhook: true } : {}),
177
276
  },
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,
178
290
  };
179
291
 
180
- if (description) {
181
- frontmatter.excerpt = description;
292
+ return stringifyFrontmatter(frontmatter);
293
+ }
294
+
295
+ /**
296
+ * Build a category landing page (mirrors what the ReadMe platform generates on
297
+ * OAS upload): `title` is the tag name for a tagged group, or the raw path for
298
+ * an untagged path-derived group (see `operationGroup`); `excerpt`, when given,
299
+ * is the tag's description from the spec's top-level `tags` array.
300
+ */
301
+ function buildTagIndexContent(title, description) {
302
+ const frontmatter = { title };
303
+ if (description) frontmatter.excerpt = description;
304
+ // As with operation pages, upload always stamps hidden: false on new pages.
305
+ frontmatter.hidden = false;
306
+
307
+ return stringifyFrontmatter(frontmatter);
308
+ }
309
+
310
+ /**
311
+ * The category-folder grouping for an operation. A tagged operation groups
312
+ * under its own tag, as before. An untagged operation groups under a folder
313
+ * derived from its path, with the raw path as the category page's title — one
314
+ * folder per unique path, not a single shared bucket. This mirrors the
315
+ * platform's own OAS-upload output: untagged operations are never lumped into
316
+ * one "Other" folder.
317
+ */
318
+ 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();
321
+ return { folder, title: op.path };
322
+ }
323
+
324
+ /**
325
+ * Collect every slug already used across the entire reference/ tree, as a
326
+ * lowercase-slug -> owner-count map. Reference page slugs share one flat
327
+ * namespace (docs/ is a separate namespace and is not consulted), so a
328
+ * generated operation slug must be unique against all of them. A page's slug
329
+ * is its filename without `.md`; a category page's slug (a folder containing
330
+ * `index.md`) is the folder name.
331
+ *
332
+ * A count, not a Set, because two existing pages or folders can already share
333
+ * a slug (hand-authored content, or content that predates this uniqueness
334
+ * logic) — a Set would collapse them to one entry, and releasing one owner
335
+ * (see `releaseSlug`) would incorrectly free the slug while the other owner
336
+ * still holds it.
337
+ */
338
+ function collectReferenceSlugs(refDir) {
339
+ const counts = new Map();
340
+
341
+ function walk(dir) {
342
+ for (const entry of fs.readdirSync(dir)) {
343
+ const full = path.join(dir, entry);
344
+ let stat;
345
+ try {
346
+ stat = fs.statSync(full);
347
+ } catch {
348
+ continue;
349
+ }
350
+ if (stat.isDirectory()) {
351
+ walk(full);
352
+ } else if (entry.endsWith('.md')) {
353
+ // A folder's index.md contributes the folder name as a slug; any other
354
+ // page contributes its own filename.
355
+ const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md');
356
+ takeSlug(counts, slug);
357
+ }
358
+ }
182
359
  }
183
360
 
184
- return matter.stringify('', frontmatter);
361
+ walk(refDir);
362
+ return counts;
363
+ }
364
+
365
+ function isSlugTaken(takenSlugs, slug) {
366
+ return (takenSlugs.get(slug.toLowerCase()) || 0) > 0;
367
+ }
368
+
369
+ /** Record one more owner of `slug`. */
370
+ function takeSlug(takenSlugs, slug) {
371
+ const key = slug.toLowerCase();
372
+ takenSlugs.set(key, (takenSlugs.get(key) || 0) + 1);
373
+ }
374
+
375
+ /** Record one fewer owner of `slug`; only fully frees it once every owner is gone. */
376
+ function releaseSlug(takenSlugs, slug) {
377
+ const key = slug.toLowerCase();
378
+ const remaining = (takenSlugs.get(key) || 0) - 1;
379
+ if (remaining > 0) takenSlugs.set(key, remaining);
380
+ else takenSlugs.delete(key);
381
+ }
382
+
383
+ /**
384
+ * Reserve a unique reference slug. `index` is never usable by an operation (it's
385
+ * reserved for the tag category page), and any slug already present in the
386
+ * reference namespace gets a numeric suffix (`-1`, `-2`, ...) until it's free.
387
+ * The chosen slug gains an owner in `takenSlugs` so later operations see it.
388
+ */
389
+ function reserveSlug(takenSlugs, base) {
390
+ let chosen = base;
391
+ if (base === 'index' || isSlugTaken(takenSlugs, base)) {
392
+ let n = 1;
393
+ while (isSlugTaken(takenSlugs, `${base}-${n}`)) n += 1;
394
+ chosen = `${base}-${n}`;
395
+ }
396
+ takeSlug(takenSlugs, chosen);
397
+ return chosen;
185
398
  }
186
399
 
187
400
  /**
188
401
  * Run the sync for a single OAS file. Returns changes for that file.
402
+ *
403
+ * `takenSlugs` is the reference-wide set of slugs already in use; it is read and
404
+ * mutated so slugs stay unique across every spec processed in one sync run.
189
405
  */
190
- function syncOneOas(refDir, oasFilename, spec) {
406
+ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
191
407
  const specOps = extractOperations(spec);
192
- const infoTitle = spec.info?.title || path.basename(oasFilename, path.extname(oasFilename));
193
- const skipUpdates = isReadMeConfig(spec);
408
+ const infoTitle = safeSegment(
409
+ spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
410
+ 'api',
411
+ );
194
412
 
195
413
  const existingPages = collectExistingPages(refDir).filter(
196
414
  (p) => p.data.api.file === oasFilename,
@@ -198,10 +416,21 @@ function syncOneOas(refDir, oasFilename, spec) {
198
416
 
199
417
  const pagesByOpId = new Map();
200
418
  for (const page of existingPages) {
201
- pagesByOpId.set(page.data.api.operationId, page);
419
+ pagesByOpId.set(
420
+ operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
421
+ page,
422
+ );
202
423
  }
203
424
 
204
- const changes = { added: [], deleted: [], updated: [] };
425
+ const changes = { added: [], deleted: [], skipped: [] };
426
+
427
+ // Tag descriptions from the spec's top-level `tags` array, used for the
428
+ // per-tag category landing page (index.md).
429
+ const tagDescriptions = new Map(
430
+ (Array.isArray(spec.tags) ? spec.tags : [])
431
+ .filter((t) => t && t.name)
432
+ .map((t) => [t.name, t.description || null]),
433
+ );
205
434
 
206
435
  // Deletes: pages referencing operations that no longer exist.
207
436
  for (const [opId, page] of pagesByOpId) {
@@ -209,68 +438,100 @@ function syncOneOas(refDir, oasFilename, spec) {
209
438
  fs.unlinkSync(page.filePath);
210
439
 
211
440
  const pageDir = path.dirname(page.filePath);
212
- const slug = path.basename(page.filePath, '.md');
213
- removeFromOrder(path.join(pageDir, '_order.yaml'), slug);
441
+ // A legacy operation page can be literally named index.md (predating
442
+ // the "index is reserved for the category page" convention). Two
443
+ // different things need two different values here: pageSlug is what a
444
+ // pre-refactor tool would have actually written into pageDir's own
445
+ // _order.yaml ("index", the filename) — that's what removeFromOrder
446
+ // must remove. referenceSlug is what the reference-wide slug map
447
+ // reserved for it (its folder name, like any index.md — see
448
+ // collectReferenceSlugs) — that's what releaseSlug must free.
449
+ const isIndexPage = path.basename(page.filePath) === 'index.md';
450
+ const pageSlug = path.basename(page.filePath, '.md');
451
+ const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
452
+ removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
453
+ releaseSlug(takenSlugs, referenceSlug);
214
454
 
215
455
  changes.deleted.push(page.relativePath);
216
456
  }
217
457
  }
218
458
 
219
- // Adds + Updates.
220
- for (const [opId, op] of specOps) {
221
- const existing = pagesByOpId.get(opId);
222
- const tag = op.tag || 'Other';
459
+ // Ensure every group (a tag, or a path-derived bucket for untagged
460
+ // operations) present in the spec has its category landing page (index.md)
461
+ // and is ordered — independent of whether its operation pages are new. Doing
462
+ // this as its own pass (rather than only when creating a new op page) backfills
463
+ // category pages for references first synced by a CLI version that didn't
464
+ // generate them, and recreates one that was deleted.
465
+ const groupsByFolder = new Map();
466
+ for (const op of specOps.values()) {
467
+ const { folder, title } = operationGroup(op);
468
+ if (!groupsByFolder.has(folder)) {
469
+ groupsByFolder.set(folder, { title, description: op.tag ? tagDescriptions.get(op.tag) : null });
470
+ }
471
+ }
223
472
 
224
- if (!existing) {
225
- const pageDir = path.join(refDir, infoTitle, tag);
473
+ // Order groups the way the platform does: a tag keeps the position it's
474
+ // declared in the spec's own top-level `tags` array, not the order its
475
+ // operations happen to appear in `paths`. A group with no declared position
476
+ // (an untagged path-derived group, or a tag used by an operation but never
477
+ // listed in `tags`) keeps its natural encounter order, appended after every
478
+ // declared tag.
479
+ const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
480
+ .filter((t) => t && t.name)
481
+ .map((t) => safeSegment(t.name, 'Other').toLowerCase());
482
+ const orderedFolders = [
483
+ ...declaredOrder.filter((folder) => groupsByFolder.has(folder)),
484
+ ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)),
485
+ ];
486
+
487
+ for (const folder of orderedFolders) {
488
+ const { title, description } = groupsByFolder.get(folder);
489
+ const pageDir = path.join(refDir, infoTitle, folder);
490
+ if (!isWithin(refDir, pageDir)) continue;
491
+
492
+ const indexPath = path.join(pageDir, 'index.md');
493
+ if (!fs.existsSync(indexPath)) {
494
+ // Never overwrite an existing index.md — it may be a hand-written category.
226
495
  fs.mkdirSync(pageDir, { recursive: true });
496
+ fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
497
+ changes.added.push(path.relative(refDir, indexPath));
498
+ // The category page's slug is the folder name; reserve it so no operation
499
+ // takes it. Only when just-created — an existing index.md was already
500
+ // counted by collectReferenceSlugs's initial disk walk.
501
+ takeSlug(takenSlugs, folder);
502
+ }
503
+ addToOrder(path.join(refDir, infoTitle, '_order.yaml'), folder);
504
+ addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
505
+ }
227
506
 
228
- const pagePath = path.join(pageDir, `${opId}.md`);
229
- const content = buildPageContent({
230
- oasFilename,
231
- operationId: opId,
232
- summary: op.summary,
233
- description: op.description,
234
- });
235
- fs.writeFileSync(pagePath, content);
236
-
237
- addToOrder(path.join(pageDir, '_order.yaml'), opId);
238
- addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
239
-
240
- changes.added.push(path.relative(refDir, pagePath));
241
- } else if (!skipUpdates) {
242
- const expectedTitle = op.summary || opId;
243
- const expectedExcerpt = op.description || null;
244
- const currentTitle = existing.data.title;
245
- const currentExcerpt = existing.data.excerpt || null;
246
-
247
- const titleChanged = currentTitle !== expectedTitle;
248
- const excerptChanged = currentExcerpt !== expectedExcerpt;
249
-
250
- if (titleChanged || excerptChanged) {
251
- const updated = { ...existing.data };
252
- const updateDetails = [];
253
-
254
- if (titleChanged) {
255
- updated.title = expectedTitle;
256
- updateDetails.push('title');
257
- }
258
- if (excerptChanged) {
259
- if (expectedExcerpt) {
260
- updated.excerpt = expectedExcerpt;
261
- } else {
262
- delete updated.excerpt;
263
- }
264
- updateDetails.push('excerpt');
265
- }
507
+ // Adds: operation pages with no page yet. Title/excerpt are owned by the OAS
508
+ // spec at render time, so generated pages carry only the api reference. Slugs
509
+ // are lowercased to match the platform's OAS-upload output.
510
+ for (const [key, op] of specOps) {
511
+ if (pagesByOpId.has(key)) continue;
512
+
513
+ const { folder } = operationGroup(op);
514
+ const pageDir = path.join(refDir, infoTitle, folder);
515
+ // Reference slugs share one flat namespace, so uniquify against every slug
516
+ // already in reference/ — a collision (or the reserved `index` slug) gets a
517
+ // numeric suffix rather than being skipped.
518
+ const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
519
+ const pagePath = path.join(pageDir, `${slug}.md`);
520
+
521
+ // Guard against a spec-crafted name escaping reference/, or a stale slug set
522
+ // vs. disk. reserveSlug already prevents slug collisions.
523
+ if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
524
+ changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
525
+ continue;
526
+ }
527
+ fs.mkdirSync(pageDir, { recursive: true });
266
528
 
267
- const body = matter(existing.content).content;
268
- const newContent = matter.stringify(body, updated);
269
- fs.writeFileSync(existing.filePath, newContent);
529
+ const content = buildPageContent({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook });
530
+ fs.writeFileSync(pagePath, content);
270
531
 
271
- changes.updated.push(`${existing.relativePath} (${updateDetails.join(', ')})`);
272
- }
273
- }
532
+ addToOrder(path.join(pageDir, '_order.yaml'), slug);
533
+
534
+ changes.added.push(path.relative(refDir, pagePath));
274
535
  }
275
536
 
276
537
  return changes;
@@ -285,7 +546,7 @@ function syncOneOas(refDir, oasFilename, spec) {
285
546
  *
286
547
  * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
287
548
  * @returns {null | Array<{ filename: string, spec: object, opCount: number,
288
- * changes: { added: string[], deleted: string[], updated: string[] } }>}
549
+ * changes: { added: string[], deleted: string[] } }>}
289
550
  * Returns null if there's no reference/ dir or no specs.
290
551
  */
291
552
  export function syncOas(input) {
@@ -296,11 +557,14 @@ export function syncOas(input) {
296
557
  const oasFiles = findOasFiles(refDir);
297
558
  if (oasFiles.length === 0) return null;
298
559
 
560
+ // Reference slugs share one flat namespace across every spec, so build the set
561
+ // of in-use slugs once and let each spec read/extend it.
562
+ const takenSlugs = collectReferenceSlugs(refDir);
299
563
  const allChanges = [];
300
564
 
301
565
  for (const { filename, spec } of oasFiles) {
302
566
  const ops = extractOperations(spec);
303
- const changes = syncOneOas(refDir, filename, spec);
567
+ const changes = syncOneOas(refDir, filename, spec, takenSlugs);
304
568
  allChanges.push({ filename, spec, opCount: ops.size, changes });
305
569
  }
306
570
 
@@ -325,11 +589,12 @@ export async function run(_options, _cmd, ctx) {
325
589
 
326
590
  let totalAdded = 0;
327
591
  let totalDeleted = 0;
328
- let totalUpdated = 0;
592
+ let totalSkipped = 0;
329
593
 
330
594
  for (const { filename, spec, opCount, changes } of results) {
331
595
  const title = spec.info?.title || filename;
332
- const hasChanges = changes.added.length + changes.deleted.length + changes.updated.length > 0;
596
+ const hasChanges =
597
+ changes.added.length + changes.deleted.length + changes.skipped.length > 0;
333
598
 
334
599
  const dot = hasChanges ? styles.warn('●') : styles.success('●');
335
600
  console.log();
@@ -345,20 +610,25 @@ export async function run(_options, _cmd, ctx) {
345
610
  for (const file of changes.deleted) {
346
611
  console.log(` ${styles.err('−')} Deleted ${file}`);
347
612
  }
348
- for (const file of changes.updated) {
349
- console.log(` ${styles.warn('~')} Updated ${file}`);
613
+ for (const { path: file, operationId } of changes.skipped) {
614
+ console.log(
615
+ ` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
616
+ );
350
617
  }
351
618
 
352
619
  totalAdded += changes.added.length;
353
620
  totalDeleted += changes.deleted.length;
354
- totalUpdated += changes.updated.length;
621
+ totalSkipped += changes.skipped.length;
355
622
  }
356
623
 
357
624
  console.log();
358
- const total = totalAdded + totalDeleted + totalUpdated;
359
- if (total === 0) {
625
+ const total = totalAdded + totalDeleted;
626
+ const skippedNote = totalSkipped > 0 ? `, ${totalSkipped} skipped` : '';
627
+ if (total === 0 && totalSkipped === 0) {
360
628
  styles.ok('Reference pages are already in sync.');
629
+ } else if (total === 0) {
630
+ styles.warning(`No pages synced; ${totalSkipped} skipped (destination already exists).`);
361
631
  } else {
362
- styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted, ${totalUpdated} updated.`);
632
+ styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${skippedNote}.`);
363
633
  }
364
634
  }