@readme/cli 0.0.29 → 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.
@@ -56,29 +56,104 @@ function generateOperationId(method, pathStr) {
56
56
  }
57
57
 
58
58
  /**
59
- * Extract operations from an OAS spec.
60
- * Returns a Map of operationId -> { summary, description, tag, operationId }.
61
- * For operations without an operationId, a synthetic one is generated from the method and path.
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).
62
129
  */
63
130
  export function extractOperations(spec) {
64
131
  const ops = new Map();
65
- const paths = spec.paths || {};
66
132
 
67
- for (const [pathStr, methods] of Object.entries(paths)) {
68
- for (const [method, operation] of Object.entries(methods)) {
69
- if (!HTTP_METHODS.has(method)) continue;
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;
70
139
 
71
- const operationId = operation.operationId || generateOperationId(method, pathStr);
140
+ const operationId = operation.operationId || generateOperationId(method, pathStr);
72
141
 
73
- ops.set(operationId, {
74
- operationId,
75
- summary: operation.summary || null,
76
- description: operation.description || null,
77
- tag: (operation.tags && operation.tags[0]) || null,
78
- });
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
+ }
79
151
  }
80
152
  }
81
153
 
154
+ collect(spec.paths || {}, false);
155
+ collect(spec.webhooks || {}, true);
156
+
82
157
  return ops;
83
158
  }
84
159
 
@@ -179,21 +254,156 @@ function isWithin(baseDir, target) {
179
254
  );
180
255
  }
181
256
 
182
- function buildPageContent({ oasFilename, operationId }) {
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 }) {
183
268
  const frontmatter = {
184
269
  api: {
185
270
  file: oasFilename,
186
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 } : {}),
187
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,
188
290
  };
189
291
 
190
- return matter.stringify('', frontmatter);
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
+ }
359
+ }
360
+
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;
191
398
  }
192
399
 
193
400
  /**
194
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.
195
405
  */
196
- function syncOneOas(refDir, oasFilename, spec) {
406
+ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
197
407
  const specOps = extractOperations(spec);
198
408
  const infoTitle = safeSegment(
199
409
  spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
@@ -206,48 +416,120 @@ function syncOneOas(refDir, oasFilename, spec) {
206
416
 
207
417
  const pagesByOpId = new Map();
208
418
  for (const page of existingPages) {
209
- 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
+ );
210
423
  }
211
424
 
212
425
  const changes = { added: [], deleted: [], skipped: [] };
213
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
+ );
434
+
214
435
  // Deletes: pages referencing operations that no longer exist.
215
436
  for (const [opId, page] of pagesByOpId) {
216
437
  if (!specOps.has(opId)) {
217
438
  fs.unlinkSync(page.filePath);
218
439
 
219
440
  const pageDir = path.dirname(page.filePath);
220
- const slug = path.basename(page.filePath, '.md');
221
- 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);
222
454
 
223
455
  changes.deleted.push(page.relativePath);
224
456
  }
225
457
  }
226
458
 
227
- // Adds: operations with no page yet. Title/excerpt are owned by the OAS spec
228
- // at render time, so generated pages carry only the api reference.
229
- for (const [opId, op] of specOps) {
230
- if (pagesByOpId.has(opId)) continue;
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
+ }
472
+
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.
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
+ }
231
506
 
232
- const tag = safeSegment(op.tag || 'Other', 'Other');
233
- const slug = safeSegment(opId, 'operation');
234
- const pageDir = path.join(refDir, infoTitle, tag);
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());
235
519
  const pagePath = path.join(pageDir, `${slug}.md`);
236
520
 
237
- // Never overwrite an existing file: it belongs to a manual page, another
238
- // spec, or a different operation whose sanitized name collides with this
239
- // one. Skipping (rather than clobbering) keeps repeated syncs stable.
521
+ // Guard against a spec-crafted name escaping reference/, or a stale slug set
522
+ // vs. disk. reserveSlug already prevents slug collisions.
240
523
  if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
241
- changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: opId });
524
+ changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
242
525
  continue;
243
526
  }
244
527
  fs.mkdirSync(pageDir, { recursive: true });
245
528
 
246
- const content = buildPageContent({ oasFilename, operationId: opId });
529
+ const content = buildPageContent({ oasFilename, operationId: op.operationId, isWebhook: op.isWebhook });
247
530
  fs.writeFileSync(pagePath, content);
248
531
 
249
532
  addToOrder(path.join(pageDir, '_order.yaml'), slug);
250
- addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
251
533
 
252
534
  changes.added.push(path.relative(refDir, pagePath));
253
535
  }
@@ -275,11 +557,14 @@ export function syncOas(input) {
275
557
  const oasFiles = findOasFiles(refDir);
276
558
  if (oasFiles.length === 0) return null;
277
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);
278
563
  const allChanges = [];
279
564
 
280
565
  for (const { filename, spec } of oasFiles) {
281
566
  const ops = extractOperations(spec);
282
- const changes = syncOneOas(refDir, filename, spec);
567
+ const changes = syncOneOas(refDir, filename, spec, takenSlugs);
283
568
  allChanges.push({ filename, spec, opCount: ops.size, changes });
284
569
  }
285
570
 
package/src/utils/lint.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { pathToFileURL } from 'node:url'
3
+ import { pathToFileURL, fileURLToPath } from 'node:url'
4
4
 
5
5
  const TARGET_PATTERNS = [
6
6
  { dir: 'custom_blocks', ext: ['.mdx', '.md'] },
@@ -38,7 +38,7 @@ export function collectFiles(gitRoot) {
38
38
  * `validateAll()` for cross-file checks (like ordering).
39
39
  */
40
40
  async function loadValidators() {
41
- const validatorsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'validators')
41
+ const validatorsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'validators')
42
42
  const files = fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js'))
43
43
  const validators = []
44
44
 
@@ -140,6 +140,51 @@ test('parseLlmsTxt: items with no H2 fall into implicit Resources section', () =
140
140
  assert.equal(result.sections[0].title, 'Resources')
141
141
  })
142
142
 
143
+ test('parseLlmsTxt: strips trailing (section) markers from H2 titles', () => {
144
+ const body = [
145
+ '# My Docs',
146
+ '',
147
+ '## getting-started (section)',
148
+ '- [Overview](https://docs.example.com/overview)',
149
+ '',
150
+ '## API Reference (SECTION)',
151
+ '- [Pets](https://docs.example.com/pets)',
152
+ '',
153
+ '## Guides (section) extra',
154
+ '- [Install](https://docs.example.com/install)',
155
+ ].join('\n')
156
+
157
+ const result = parseLlmsTxt(body, BASE)
158
+ assert.deepEqual(
159
+ result.sections.map((s) => s.title),
160
+ ['getting-started', 'API Reference', 'Guides (section) extra'],
161
+ )
162
+ })
163
+
164
+ test('parseLlmsTxt: strips (section) markers when falling back to H3 headings', () => {
165
+ const manyItems = Array.from({ length: 201 }, (_, i) => `- [Page ${i}](https://docs.example.com/page-${i})`).join('\n')
166
+ const body = [
167
+ '# Big Site',
168
+ '',
169
+ '## Docs',
170
+ manyItems,
171
+ '',
172
+ '### getting-started (section)',
173
+ '- [A1](https://docs.example.com/a1)',
174
+ '',
175
+ '### Reference (section)',
176
+ '- [B1](https://docs.example.com/b1)',
177
+ ].join('\n')
178
+
179
+ const result = parseLlmsTxt(body, BASE)
180
+ assert.equal(result.h3Fallback, true)
181
+ const titles = result.sections.map((s) => s.title)
182
+ assert.ok(titles.includes('getting-started'))
183
+ assert.ok(titles.includes('Reference'))
184
+ assert.ok(!titles.includes('getting-started (section)'))
185
+ assert.ok(!titles.includes('Reference (section)'))
186
+ })
187
+
143
188
  // ---------------------------------------------------------------------------
144
189
  // parseLlmsTxt — H3 fallback
145
190
  // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
5
6
  import ora from "ora";
6
7
  import * as styles from "./styles.js";
7
8
  import { hasClaude } from "./claude.js";
@@ -177,7 +178,7 @@ export function createHumanReporter() {
177
178
  // When running inside Claude, output instructions and structured issue list.
178
179
  if (isRunningInClaude && unfixed.length > 0) {
179
180
  const cliRoot = path.join(
180
- path.dirname(new URL(import.meta.url).pathname),
181
+ path.dirname(fileURLToPath(import.meta.url)),
181
182
  "../..",
182
183
  );
183
184
 
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Canonical slug for a git-format page path. `index.md`/`index.mdx` carries
5
+ * its parent folder's slug (e.g. `docs/a/setup/index.md` → "setup", matching
6
+ * how gitto slugs a folder from its index page); any other file is its
7
+ * filename minus the extension. A root-level index file falls back to "index".
8
+ */
9
+ export function slugForEntry(relPath) {
10
+ const filename = path.basename(relPath);
11
+ if (filename === 'index.md' || filename === 'index.mdx') {
12
+ const parentDir = path.basename(path.dirname(relPath));
13
+ return parentDir && parentDir !== '.' && parentDir !== path.sep ? parentDir : 'index';
14
+ }
15
+ return filename.replace(/\.(md|mdx)$/, '');
16
+ }
@@ -135,19 +135,22 @@ export function validate({ content, relativePath }) {
135
135
 
136
136
  const results = [];
137
137
 
138
- // .md snippet files: filename must be PascalCase (it becomes the component tag).
138
+ // .md snippet files: the filename becomes a component tag <Name />, so it must
139
+ // be a valid JS identifier starting with an uppercase letter. Underscores and
140
+ // digits are fine (<Helm_v4_error /> is valid); hyphens and a lowercase initial
141
+ // are not.
139
142
  if (isMd) {
140
143
  const slug = filename.replace(/\.md$/, "");
141
- const pascalCase = slug
142
- .split(/[-_]/)
143
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
144
- .join("");
145
- if (slug !== pascalCase) {
144
+ if (!/^[A-Z][A-Za-z0-9_$]*$/.test(slug)) {
145
+ const suggestion = slug
146
+ .split(/[-_]/)
147
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
148
+ .join("");
146
149
  results.push({
147
150
  file: relativePath,
148
151
  rule: name,
149
152
  severity: "warning",
150
- message: `Bad filename: should be PascalCase — rename to "${pascalCase}.md" so it can be used as <${pascalCase} />`,
153
+ message: `Bad filename: "${slug}" can't be used as a component tag — rename to "${suggestion}.md" so it can be used as <${suggestion} />`,
151
154
  });
152
155
  }
153
156
  return results.length > 0 ? results : null;
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { slugForEntry } from '../utils/slug-for-entry.js';
2
3
 
3
4
  export const name = 'duplicates';
4
5
 
@@ -14,10 +15,7 @@ export function validateAll(files) {
14
15
  const topDir = relPath.split('/')[0];
15
16
  if (!CHECKED_DIRS.includes(topDir)) continue;
16
17
 
17
- const filename = path.basename(relPath);
18
- if (filename === 'index.md' || filename === 'index.mdx') continue;
19
-
20
- const slug = filename.replace(/\.(md|mdx)$/, '');
18
+ const slug = slugForEntry(relPath);
21
19
  const key = `${topDir}:${slug}`;
22
20
  if (!slugMap.has(key)) slugMap.set(key, []);
23
21
  slugMap.get(key).push(relPath);
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import matter from 'gray-matter';
4
+ import { slugForEntry } from '../utils/slug-for-entry.js';
4
5
 
5
6
  export const name = 'links';
6
7
 
@@ -19,14 +20,7 @@ function collectSlugs(gitRoot) {
19
20
  const entries = fs.readdirSync(dirPath, { recursive: true });
20
21
  for (const entry of entries) {
21
22
  if (!/\.(md|mdx)$/.test(entry)) continue;
22
- const filename = path.basename(entry);
23
- if (filename === 'index.md' || filename === 'index.mdx') {
24
- // index.md represents the parent directory, e.g. plans-and-pricing/index.md -> slug "plans-and-pricing"
25
- const parentDir = path.basename(path.dirname(entry));
26
- if (parentDir && parentDir !== '.') slugs.add(parentDir);
27
- } else {
28
- slugs.add(filename.replace(/\.(md|mdx)$/, ''));
29
- }
23
+ slugs.add(slugForEntry(entry));
30
24
  }
31
25
  }
32
26
 
@@ -1,7 +1,7 @@
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 } from '../commands/oas-sync.js';
4
+ import { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js';
5
5
 
6
6
  export const name = 'oas-reference';
7
7
 
@@ -34,6 +34,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
34
34
 
35
35
  const oasFilename = data.api.file;
36
36
  const operationId = data.api.operationId;
37
+ const isWebhook = !!data.api.webhook;
37
38
  const oas = oasMap.get(oasFilename);
38
39
 
39
40
  // Check: OAS file doesn't exist.
@@ -50,7 +51,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
50
51
  if (!operationId) continue;
51
52
 
52
53
  // Check: operationId doesn't exist in the spec.
53
- if (!oas.ops.has(operationId)) {
54
+ if (!oas.ops.has(operationKey({ operationId, isWebhook }))) {
54
55
  results.push({
55
56
  file: relPath,
56
57
  rule: name,
@@ -67,15 +68,19 @@ export function validateAll(files, gitRoot, { fix } = {}) {
67
68
  const existingPages = collectExistingPages(refDir);
68
69
  for (const [oasFilename, { ops }] of oasMap) {
69
70
  const pagesForOas = existingPages.filter((p) => p.data.api.file === oasFilename);
70
- const coveredOps = new Set(pagesForOas.map((p) => p.data.api.operationId));
71
-
72
- for (const [opId] of ops) {
73
- if (!coveredOps.has(opId)) {
71
+ const coveredOps = new Set(
72
+ pagesForOas.map((p) =>
73
+ operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook }),
74
+ ),
75
+ );
76
+
77
+ for (const op of ops.values()) {
78
+ if (!coveredOps.has(operationKey(op))) {
74
79
  results.push({
75
80
  file: `reference/${oasFilename}`,
76
81
  rule: name,
77
82
  severity: 'warning',
78
- message: `Missing page: no reference page found for operation "${opId}"`,
83
+ message: `Missing page: no reference page found for operation "${op.operationId}"`,
79
84
  fixable: true,
80
85
  });
81
86
  }