@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.
- package/package.json +5 -5
- package/src/cli.js +3 -3
- package/src/commands/import-oas.test.js +277 -0
- package/src/commands/import.classify.test.js +172 -0
- package/src/commands/import.js +2848 -443
- package/src/commands/import.landing.test.js +217 -0
- package/src/commands/import.slug-planner.test.js +86 -0
- package/src/commands/import.staging.test.js +158 -0
- package/src/commands/import.test.js +382 -0
- package/src/commands/oas-sync.js +362 -92
- package/src/prompts/index.js +101 -13
- package/src/utils/lint.js +2 -2
- package/src/utils/llms.js +224 -0
- package/src/utils/llms.test.js +302 -0
- package/src/utils/reporter.js +2 -1
- package/src/utils/slug-for-entry.js +16 -0
- package/src/utils/url-segs.js +124 -0
- package/src/utils/url-segs.test.js +239 -0
- package/src/validators/components.js +11 -7
- package/src/validators/duplicates.js +8 -8
- package/src/validators/frontmatter.js +11 -0
- package/src/validators/links.js +2 -8
- package/src/validators/oas-reference.js +13 -38
- package/src/validators/ordering.js +169 -83
- package/vendor/TOOLS.md +1 -1
- package/src/dev/.next/app-build-manifest.json +0 -20
- package/src/dev/.next/build-manifest.json +0 -31
- package/src/dev/.next/cache/.rscinfo +0 -1
- package/src/dev/.next/cache/next-devtools-config.json +0 -1
- package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/package.json +0 -1
- package/src/dev/.next/prerender-manifest.json +0 -11
- package/src/dev/.next/react-loadable-manifest.json +0 -1
- package/src/dev/.next/routes-manifest.json +0 -1
- package/src/dev/.next/server/app/[...slug]/page.js +0 -360
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app/page.js +0 -349
- package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app-paths-manifest.json +0 -3
- package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
- package/src/dev/.next/server/middleware-build-manifest.js +0 -33
- package/src/dev/.next/server/middleware-manifest.json +0 -32
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/src/dev/.next/server/middleware.js +0 -1113
- package/src/dev/.next/server/next-font-manifest.js +0 -1
- package/src/dev/.next/server/next-font-manifest.json +0 -1
- package/src/dev/.next/server/pages-manifest.json +0 -5
- package/src/dev/.next/server/server-reference-manifest.js +0 -1
- package/src/dev/.next/server/server-reference-manifest.json +0 -5
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
- package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
- package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
- package/src/dev/.next/server/webpack-runtime.js +0 -209
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
- package/src/dev/.next/static/chunks/app/layout.js +0 -171
- package/src/dev/.next/static/chunks/app/page.js +0 -28
- package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
- package/src/dev/.next/static/chunks/main-app.js +0 -1882
- package/src/dev/.next/static/chunks/polyfills.js +0 -1
- package/src/dev/.next/static/chunks/webpack.js +0 -1393
- package/src/dev/.next/static/css/app/layout.css +0 -559
- package/src/dev/.next/static/development/_buildManifest.js +0 -1
- package/src/dev/.next/static/development/_ssgManifest.js +0 -1
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
- package/src/dev/.next/trace +0 -21
- package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
- package/src/dev/.next/types/app/layout.ts +0 -84
- package/src/dev/.next/types/app/page.ts +0 -84
- package/src/dev/.next/types/cache-life.d.ts +0 -141
- package/src/dev/.next/types/package.json +0 -1
- package/src/dev/.next/types/routes.d.ts +0 -55
- package/src/validators/numbering.js +0 -134
package/src/commands/oas-sync.js
CHANGED
|
@@ -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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
74
|
-
for (const [
|
|
75
|
-
|
|
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
|
-
|
|
140
|
+
const operationId = operation.operationId || generateOperationId(method, pathStr);
|
|
78
141
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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 =
|
|
193
|
-
|
|
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(
|
|
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: [],
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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[]
|
|
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
|
|
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 =
|
|
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.
|
|
349
|
-
console.log(
|
|
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
|
-
|
|
621
|
+
totalSkipped += changes.skipped.length;
|
|
355
622
|
}
|
|
356
623
|
|
|
357
624
|
console.log();
|
|
358
|
-
const total = totalAdded + totalDeleted
|
|
359
|
-
|
|
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
|
|
632
|
+
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${skippedNote}.`);
|
|
363
633
|
}
|
|
364
634
|
}
|