@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.
- package/package.json +3 -3
- package/src/cli.js +2 -2
- package/src/commands/import-oas.test.js +277 -0
- package/src/commands/import.classify.test.js +172 -0
- package/src/commands/import.js +793 -92
- package/src/commands/import.landing.test.js +217 -0
- package/src/commands/import.staging.test.js +158 -0
- package/src/commands/import.test.js +246 -0
- package/src/commands/oas-sync.js +319 -34
- package/src/utils/lint.js +2 -2
- package/src/utils/llms.test.js +45 -0
- package/src/utils/reporter.js +2 -1
- package/src/utils/slug-for-entry.js +16 -0
- package/src/validators/components.js +10 -7
- package/src/validators/duplicates.js +2 -4
- package/src/validators/links.js +2 -8
- package/src/validators/oas-reference.js +12 -7
package/src/commands/oas-sync.js
CHANGED
|
@@ -56,29 +56,104 @@ function generateOperationId(method, pathStr) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
68
|
-
for (const [
|
|
69
|
-
|
|
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
|
-
|
|
140
|
+
const operationId = operation.operationId || generateOperationId(method, pathStr);
|
|
72
141
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
238
|
-
//
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
|
package/src/utils/llms.test.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/utils/reporter.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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
|
|
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);
|
package/src/validators/links.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 "${
|
|
83
|
+
message: `Missing page: no reference page found for operation "${op.operationId}"`,
|
|
79
84
|
fixable: true,
|
|
80
85
|
});
|
|
81
86
|
}
|