@dogsbay/format-openapi 0.2.0-beta.13 → 0.2.0-beta.130

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/dist/core.js ADDED
@@ -0,0 +1,1224 @@
1
+ /**
2
+ * The OpenAPI conversion with no file or network access, and no Node
3
+ * built-ins: it runs in a Cloudflare Worker (dogsbay-ssg) as well as in
4
+ * the CLI. A spec comes in as text (or an already-parsed object) and goes
5
+ * out as `endpoint` TreeNodes; finding and reading the file is the
6
+ * caller's (parse.ts for the CLI, a build's file map for dogsbay-ssg).
7
+ *
8
+ * text ─ parseSpecText ─▶ object ─ prepareSpec ─▶ spec ─┬ embedSpec (one page's body)
9
+ * └ specToPages (a page per operation)
10
+ *
11
+ * `prepareSpec` is the expensive step (upgrade, dereference); `createSpecCache`
12
+ * runs it once per spec file per build. `findSpec` holds the rules for which
13
+ * file a page's `:::openapi{spec}` may name.
14
+ */
15
+ import { dereference, upgrade } from "@scalar/openapi-parser";
16
+ import MarkdownIt from "markdown-it";
17
+ import { operationToSlug, slugifyTag } from "./slug.js";
18
+ import { resolveCodeSamples } from "./code-samples.js";
19
+ export { findSpec, specCandidates } from "./spec-path.js";
20
+ const HTTP_METHODS = [
21
+ "get",
22
+ "post",
23
+ "put",
24
+ "patch",
25
+ "delete",
26
+ "head",
27
+ "options",
28
+ ];
29
+ /**
30
+ * A dereferenced spec (`prepareSpec`) as a page per operation, a page per tag
31
+ * and an info page, with their nav: for a spec that IS the source.
32
+ */
33
+ export function specToPages(spec, options = {}) {
34
+ const hrefPrefix = normalisePrefix(options.hrefPrefix);
35
+ const operations = collectOperations(spec);
36
+ const { groups, ungrouped } = groupOperations(spec, operations);
37
+ const slugs = pageSlugs(groups, operations, options.reservedSlugs);
38
+ const labels = operationLabels(operations, groups);
39
+ const pages = [];
40
+ const summaries = new Map();
41
+ const groupOf = new Map();
42
+ for (const g of groups)
43
+ for (const op of g.ops)
44
+ groupOf.set(op, g);
45
+ for (const op of operations) {
46
+ const { method, path, operation, props } = op;
47
+ const group = groupOf.get(op);
48
+ const opSlug = slugs.operations.get(op);
49
+ const slug = group ? `${slugs.groups.get(group)}/${opSlug}` : opSlug;
50
+ const { title, navLabel, summary } = labels.get(op);
51
+ pages.push({
52
+ slug,
53
+ title,
54
+ tree: [{ type: "endpoint", props: structuredClone(props) }],
55
+ headings: [{ depth: 1, slug: opSlug, text: title }],
56
+ });
57
+ summaries.set(op, { slug, title, navLabel, summary, method, path, deprecated: operation.deprecated === true });
58
+ }
59
+ // A page per group (a tag, or one resource of a very large tag).
60
+ for (const g of groups) {
61
+ pages.push(buildTagPage(g.label, slugs.groups.get(g), g.description, g.ops.map((op) => summaries.get(op))));
62
+ }
63
+ // Root info page.
64
+ const info = spec.info || {};
65
+ pages.push(buildInfoPage(txt(info.title) || "API Reference", txt(info.description) ?? "", txt(info.version) ?? (typeof info.version === "number" ? String(info.version) : "")));
66
+ // Nav: the groups in order, each with its operations; ungrouped
67
+ // operations under a synthetic "Other" group.
68
+ const nav = [{ label: txt(info.title) || "API Reference", href: hrefHref(hrefPrefix, "index") }];
69
+ const entries = (ops) => ops
70
+ .map((op) => summaries.get(op))
71
+ .sort(byPathThenMethod)
72
+ .map((op) => ({ label: op.navLabel, href: hrefHref(hrefPrefix, op.slug) }));
73
+ for (const g of groups) {
74
+ nav.push({ label: g.label, href: hrefHref(hrefPrefix, slugs.groups.get(g)), children: entries(g.ops) });
75
+ }
76
+ if (ungrouped.length > 0)
77
+ nav.push({ label: "Other", children: entries(ungrouped) });
78
+ return { pages, nav };
79
+ }
80
+ /**
81
+ * Operations per group, in nav order: one group per tag (the spec's declared
82
+ * order, then first use), holding the operations whose FIRST tag it is.
83
+ *
84
+ * A tag holding more than LARGE_TAG operations across several resources is
85
+ * split into a group per resource instead. Generated specs often put
86
+ * everything under one tag (drf-yasg: all 610 operations of the AAP
87
+ * controller API under `api`), and a flat list of 610 is unusable; Swagger UI
88
+ * and Redoc readers navigate by resource. The resource is the first path
89
+ * segment after the tag's common prefix that is not a version or a parameter:
90
+ * `/api/v2/projects/{id}/` → `projects`, groups in alphabetical order.
91
+ * Operations with no resource (`/api/`) go in a "Root" group.
92
+ */
93
+ const LARGE_TAG = 50;
94
+ function groupOperations(spec, operations) {
95
+ const descriptions = new Map();
96
+ const order = [];
97
+ for (const t of spec.tags || []) {
98
+ const name = txt(t?.name);
99
+ if (!name || descriptions.has(name))
100
+ continue;
101
+ descriptions.set(name, txt(t.description) ?? "");
102
+ order.push(name);
103
+ }
104
+ const byTag = new Map();
105
+ const ungrouped = [];
106
+ for (const op of operations) {
107
+ const tag = op.props.tags?.[0];
108
+ if (!tag) {
109
+ ungrouped.push(op);
110
+ continue;
111
+ }
112
+ if (!byTag.has(tag))
113
+ byTag.set(tag, []);
114
+ byTag.get(tag).push(op);
115
+ if (!order.includes(tag))
116
+ order.push(tag);
117
+ }
118
+ const groups = [];
119
+ const splits = new Map();
120
+ for (const tag of order) {
121
+ const ops = byTag.get(tag);
122
+ const split = ops && ops.length > LARGE_TAG ? byResource(ops) : undefined;
123
+ if (split)
124
+ splits.set(tag, split);
125
+ }
126
+ for (const tag of order) {
127
+ const ops = byTag.get(tag);
128
+ if (!ops)
129
+ continue;
130
+ const split = splits.get(tag);
131
+ if (!split) {
132
+ groups.push({ label: tag, slugBase: slugifyTag(tag) || "tag", description: descriptions.get(tag) ?? "", ops, kind: "tag" });
133
+ continue;
134
+ }
135
+ // Operations of the tag with no resource (`GET /api/`, `/api/v2/`) are
136
+ // the API's root paths: "Root", or "api: Root" when several tags split.
137
+ // The tag's own name read as one more resource ("api" beside the spec's
138
+ // title page).
139
+ if (split.none.length) {
140
+ // A hand-written `Root` tag or a `root` resource would read the same.
141
+ const clash = [...byTag.keys()].some((t) => t !== tag && t.toLowerCase() === "root") || split.groups.has("root");
142
+ const label = splits.size > 1 || clash ? `${tag}: Root` : "Root";
143
+ groups.push({ label, slugBase: "root", description: descriptions.get(tag) ?? "", ops: split.none, kind: "tag", pathLabels: true });
144
+ }
145
+ // Alphabetical: a resource list is looked up by name, and spec order put
146
+ // `/api/debug/` before every `/api/v2/` resource. Code-unit order, not
147
+ // the build machine's locale.
148
+ const resources = [...split.groups].sort(([a], [b]) => {
149
+ const x = humanize(a).toLowerCase();
150
+ const y = humanize(b).toLowerCase();
151
+ return x < y ? -1 : x > y ? 1 : 0;
152
+ });
153
+ for (const [resource, members] of resources) {
154
+ // Say which tag a resource belongs to when its name alone is
155
+ // ambiguous: two split tags may both have `projects`, and a
156
+ // hand-written `users` tag may sit beside a `users` resource.
157
+ const plain = humanize(resource);
158
+ const clash = [...byTag.keys()].some((t) => t !== tag && t.toLowerCase() === plain.toLowerCase());
159
+ const label = splits.size > 1 || clash ? `${tag}: ${plain}` : plain;
160
+ groups.push({ label, slugBase: slugifyTag(resource) || "resource", description: "", ops: members, shortPaths: split.shortPaths, kind: "resource" });
161
+ }
162
+ }
163
+ // Ungrouped operations in spec order.
164
+ const index = new Map(operations.map((op, i) => [op, i]));
165
+ ungrouped.sort((a, b) => index.get(a) - index.get(b));
166
+ return { groups, ungrouped };
167
+ }
168
+ const isParam = (seg) => seg.startsWith("{");
169
+ const isVersion = (seg) => /^v\d+(?:[._-]\d+)*$/i.test(seg);
170
+ /** Share of a large tag's operations that must have a resource for the tag to split. */
171
+ const SPLIT_COVERAGE = 0.9;
172
+ /**
173
+ * A large tag's operations by resource, or undefined when it does not
174
+ * divide that way: fewer than two resources, or fewer than 90% of the
175
+ * operations with one (tenant-style `/{tenant}/…` paths, or one resource's
176
+ * sub-actions under `/projects/{id}/…`, keep the tag whole).
177
+ *
178
+ * The prefix is the run of literal segments that 90% of the paths share, so
179
+ * one stray `/healthz` does not empty it; versions after it are skipped.
180
+ */
181
+ function byResource(ops) {
182
+ const segs = ops.map((op) => op.path.split("/").filter(Boolean));
183
+ const need = Math.ceil(ops.length * SPLIT_COVERAGE);
184
+ const prefix = [];
185
+ const under = (s) => prefix.every((p, i) => s[i] === p);
186
+ for (;;) {
187
+ const d = prefix.length;
188
+ const counts = new Map();
189
+ for (const s of segs)
190
+ if (s.length > d + 1 && under(s))
191
+ counts.set(s[d], (counts.get(s[d]) ?? 0) + 1);
192
+ const top = [...counts].sort((a, b) => b[1] - a[1])[0];
193
+ // Extend while one literal segment is shared by nearly all the paths
194
+ // that go deeper than it.
195
+ if (!top || top[1] < need || isParam(top[0]))
196
+ break;
197
+ prefix.push(top[0]);
198
+ }
199
+ const groups = new Map();
200
+ const none = [];
201
+ const shortPaths = new Map();
202
+ const versions = new Map();
203
+ ops.forEach((op, k) => {
204
+ const s = segs[k];
205
+ let i = prefix.length;
206
+ while (i < s.length && isVersion(s[i]))
207
+ i++;
208
+ const resource = under(s) && i < s.length && !isParam(s[i]) ? s[i] : undefined;
209
+ if (!resource)
210
+ return void none.push(op);
211
+ if (!groups.has(resource))
212
+ groups.set(resource, []);
213
+ groups.get(resource).push(op);
214
+ versions.set(op, s.slice(prefix.length, i).join("/"));
215
+ shortPaths.set(op, "/" + s.slice(i).join("/") + (op.path.endsWith("/") ? "/" : ""));
216
+ });
217
+ if (groups.size < 2 || ops.length - none.length < need)
218
+ return undefined;
219
+ // A group spanning versions (`/api/v1/users/`, `/api/v2/users/`) keeps
220
+ // them in its nav labels, or both read `GET /users/`.
221
+ for (const members of groups.values()) {
222
+ if (new Set(members.map((op) => versions.get(op))).size < 2)
223
+ continue;
224
+ for (const op of members) {
225
+ const s = op.path.split("/").filter(Boolean);
226
+ shortPaths.set(op, "/" + s.slice(prefix.length).join("/") + (op.path.endsWith("/") ? "/" : ""));
227
+ }
228
+ }
229
+ return { groups, none, shortPaths };
230
+ }
231
+ /** A path segment as a label: `job_templates` → "Job Templates". */
232
+ function humanize(segment) {
233
+ return segment
234
+ .split(/[_-]+/)
235
+ .filter(Boolean)
236
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
237
+ .join(" ") || segment;
238
+ }
239
+ /**
240
+ * Each operation's page title and nav label: its summary when no other
241
+ * operation has the same one, otherwise its method and path. Generated specs
242
+ * copy one summary to every method of a path (drf-yasg: 151 summaries for 610
243
+ * operations, so DELETE read "Make GET request to this resource…"), and a nav
244
+ * of identical labels cannot be read. In a resource group every nav label is
245
+ * the method and the path after the prefix the group already says
246
+ * (`PATCH /projects/{id}/`).
247
+ */
248
+ function operationLabels(operations, groups) {
249
+ // A generator's placeholder is no summary: "No Description for get on /api/v2/…" (drf-yasg's exact form).
250
+ // The spec's own summary decides. One joined to the rest of its sentence
251
+ // (summaryAndDescription) is a docstring sentence, not a title: the shared
252
+ // "…retrieve the list of" becomes unique once joined, and would title every
253
+ // drf-yasg list page with a whole sentence.
254
+ const summaryOf = (op) => {
255
+ const s = txt(op.operation.summary)?.trim();
256
+ return s && !/^no description for \w+ on \//i.test(s) ? s : undefined;
257
+ };
258
+ // Counted over every operation, joined or not: two operations sharing
259
+ // "Retrieve the list of" share it whichever of them was joined.
260
+ const count = new Map();
261
+ for (const op of operations) {
262
+ const s = summaryOf(op);
263
+ if (s)
264
+ count.set(s, (count.get(s) ?? 0) + 1);
265
+ }
266
+ const joined = (op) => op.props.summary?.trim() !== summaryOf(op);
267
+ const shortPath = new Map();
268
+ for (const g of groups)
269
+ for (const op of g.ops) {
270
+ if (g.shortPaths?.has(op))
271
+ shortPath.set(op, g.shortPaths.get(op));
272
+ // The Root group sits among resource groups: the same `METHOD path` form (#104)
273
+ else if (g.pathLabels)
274
+ shortPath.set(op, op.path);
275
+ }
276
+ const out = new Map();
277
+ for (const op of operations) {
278
+ const s = summaryOf(op);
279
+ const method = op.method.toUpperCase();
280
+ const unique = s && count.get(s) === 1 && !joined(op) ? s : undefined;
281
+ // A resource group (a split, generated tag) lists method and path for
282
+ // every entry, as Swagger UI does: mixing in the few unique summaries
283
+ // made a column of near-identical prose ("Make GET request to this…").
284
+ const short = shortPath.get(op);
285
+ out.set(op, {
286
+ summary: unique,
287
+ title: unique ?? `${method} ${op.path}`,
288
+ navLabel: short !== undefined ? `${method} ${short}` : unique ?? `${method} ${op.path}`,
289
+ });
290
+ }
291
+ return out;
292
+ }
293
+ /**
294
+ * Every page's URL, distinct. The root page is `index`, a group's overview is
295
+ * `<group>`, a grouped operation `<group>/<operation>` and an ungrouped one
296
+ * `<operation>`, where `<operation>` comes from the operationId, or else the
297
+ * method and path. Slugs that would land on a URL already taken (`getPet` and
298
+ * `get_pet`; an operationId `index`; an ungrouped operation named like a
299
+ * group; two groups that slugify alike) are made distinct: an operation falls
300
+ * back to its method and path, then a numeric suffix, in spec order, so the
301
+ * first claimant keeps the plain slug. Before, the later page silently
302
+ * replaced the earlier one.
303
+ */
304
+ function pageSlugs(groups, operations, reserved = []) {
305
+ const rootTaken = new Set(["index", ...reserved.map((r) => r.toLowerCase())]);
306
+ const groupSlugs = new Map();
307
+ const folderOf = new Map();
308
+ // Tags before resources: a resource of a large tag must not take the URL
309
+ // a hand-written tag has (`users` stays at /users when the large `api`
310
+ // tag also has a users resource, which becomes /users-2).
311
+ for (const kind of ["tag", "resource"]) {
312
+ for (const g of groups)
313
+ if (g.kind === kind)
314
+ groupSlugs.set(g, claim(g.slugBase, rootTaken));
315
+ }
316
+ for (const g of groups) {
317
+ const folder = new Set(["index"]);
318
+ for (const op of g.ops)
319
+ folderOf.set(op, folder);
320
+ }
321
+ const taken = (op) => folderOf.get(op) ?? rootTaken;
322
+ // Two passes: every operation's own slug first, so a fallback never takes
323
+ // the slug a LATER operation would have had (moving a URL that had no
324
+ // collision); then the operations that lost theirs.
325
+ const out = new Map();
326
+ for (const op of operations) {
327
+ const preferred = operationToSlug(op.method, op.path, txt(op.operation.operationId));
328
+ const set = taken(op);
329
+ if (preferred && !set.has(preferred)) {
330
+ set.add(preferred);
331
+ out.set(op, preferred);
332
+ }
333
+ }
334
+ for (const op of operations) {
335
+ if (out.has(op))
336
+ continue;
337
+ // Letters, digits and dashes only: a path's `:`, `?` or `%2F` must not
338
+ // reach a URL this function invents.
339
+ const fallback = operationToSlug(op.method, op.path).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || op.method;
340
+ out.set(op, claim(fallback, taken(op)));
341
+ }
342
+ return { groups: groupSlugs, operations: out };
343
+ }
344
+ /** `base`, or `base-2`, `base-3`… : the first not in `used`, which it joins. */
345
+ function claim(base, used) {
346
+ let slug = base;
347
+ for (let n = 2; used.has(slug); n++)
348
+ slug = `${base}-${n}`;
349
+ used.add(slug);
350
+ return slug;
351
+ }
352
+ /**
353
+ * Spec text → object. JSON is read natively; YAML needs a parser passed in
354
+ * (`yaml`'s `parse`, js-yaml's `load`), so a host that has one does not
355
+ * bundle a second.
356
+ */
357
+ export function parseSpecText(text, parseYaml) {
358
+ try {
359
+ return JSON.parse(text);
360
+ }
361
+ catch (err) {
362
+ if (!parseYaml)
363
+ throw new Error(`not JSON, and no YAML parser was given (${err.message})`);
364
+ return parseYaml(text);
365
+ }
366
+ }
367
+ /**
368
+ * A parsed spec → the dereferenced OpenAPI 3 document every function here
369
+ * reads. Swagger 2.0 is upgraded to OpenAPI 3 first: everything below reads
370
+ * the 3.x shape (`requestBody`, response `content`, `servers`), so a 2.0
371
+ * spec parsed without the upgrade lost every body parameter and response
372
+ * schema while still producing plausible-looking pages. Canonical's Anbox
373
+ * Cloud API specs are 2.0.
374
+ */
375
+ export async function prepareSpec(parsed, label = "the spec") {
376
+ if (!parsed || typeof parsed !== "object")
377
+ throw new Error(`${label} is not an OpenAPI document`);
378
+ let doc = acyclic(parsed, label);
379
+ if (typeof doc.swagger === "string" && doc.swagger.startsWith("2")) {
380
+ // The upgrade moves a 2.0 parameter's type into `schema` but drops its
381
+ // `default` (`page: 1` vanished); carry it across. `doc` is acyclic's
382
+ // copy, so marking it does not touch the caller's object.
383
+ inlineSharedParams(doc);
384
+ swagger2Params(doc, (p) => {
385
+ if (p.in !== "body" && p.default !== undefined)
386
+ p[KEPT_DEFAULT] = p.default;
387
+ });
388
+ doc = upgrade(doc).specification;
389
+ swagger2ResponseExamples(doc);
390
+ swagger2Params(doc, (p) => {
391
+ if (!(KEPT_DEFAULT in p))
392
+ return;
393
+ if (p.schema && typeof p.schema === "object" && p.schema.default === undefined)
394
+ p.schema.default = p[KEPT_DEFAULT];
395
+ else if (!p.schema && p.default === undefined)
396
+ p.default = p[KEPT_DEFAULT];
397
+ delete p[KEPT_DEFAULT];
398
+ });
399
+ }
400
+ const { schema: spec } = await dereference(doc);
401
+ if (!spec)
402
+ throw new Error(`Failed to dereference OpenAPI spec at ${label}`);
403
+ return spec;
404
+ }
405
+ const KEPT_DEFAULT = "x-dogsbay-default";
406
+ /**
407
+ * A 2.0 document's `{ $ref: "#/parameters/X" }` entries replaced by copies of
408
+ * the shared parameter. The upgrade writes an empty `schema: {}` beside such a
409
+ * reference and leaves `#/parameters` where it was, so every shared
410
+ * parameter came out with type "any" (and no default).
411
+ */
412
+ function inlineSharedParams(doc) {
413
+ const shared = doc.parameters && typeof doc.parameters === "object" ? doc.parameters : undefined;
414
+ if (!shared)
415
+ return;
416
+ const resolve = (list) => Array.isArray(list)
417
+ ? list.map((p) => {
418
+ const m = typeof p?.$ref === "string" ? /^#\/parameters\/(.+)$/.exec(p.$ref) : null;
419
+ let name;
420
+ try {
421
+ name = m ? decodeURIComponent(m[1]).replace(/~1/g, "/").replace(/~0/g, "~") : undefined;
422
+ }
423
+ catch {
424
+ name = undefined; // a malformed `%`: leave the ref for the dereferencer to report
425
+ }
426
+ return name !== undefined && Object.hasOwn(shared, name) && shared[name] && typeof shared[name] === "object" ? { ...shared[name] } : p;
427
+ })
428
+ : list;
429
+ for (const item of Object.values(doc.paths ?? {})) {
430
+ if (!item || typeof item !== "object")
431
+ continue;
432
+ if (item.parameters)
433
+ item.parameters = resolve(item.parameters);
434
+ for (const m of HTTP_METHODS)
435
+ if (item[m]?.parameters)
436
+ item[m].parameters = resolve(item[m].parameters);
437
+ }
438
+ }
439
+ /**
440
+ * A 2.0 response's `examples` (media type → value) onto the upgraded
441
+ * response's `content[type].example`. The upgrade leaves them on the
442
+ * response, where 3.x has no such field, so the drf-yasg spec's 176 response
443
+ * examples were replaced by generated ones.
444
+ */
445
+ function swagger2ResponseExamples(doc) {
446
+ const fix = (r) => {
447
+ if (!r || typeof r !== "object" || !r.examples || typeof r.examples !== "object" || Array.isArray(r.examples))
448
+ return;
449
+ const content = r.content && typeof r.content === "object" ? r.content : undefined;
450
+ for (const [type, value] of Object.entries(r.examples)) {
451
+ let media = content?.[type] ?? (/json/i.test(type) ? content?.["application/json"] : undefined);
452
+ // No schema, so the upgrade wrote no content: the example is all there is.
453
+ if (!media && !content)
454
+ media = (r.content ??= {})[type] = {};
455
+ if (media && typeof media === "object" && media.example === undefined)
456
+ media.example = value;
457
+ }
458
+ delete r.examples;
459
+ };
460
+ const responses = (map) => {
461
+ if (map && typeof map === "object")
462
+ for (const r of Object.values(map))
463
+ fix(r);
464
+ };
465
+ responses(doc.responses);
466
+ responses(doc.components?.responses);
467
+ for (const item of Object.values(doc.paths ?? {})) {
468
+ if (!item || typeof item !== "object")
469
+ continue;
470
+ for (const m of HTTP_METHODS)
471
+ responses(item[m]?.responses);
472
+ }
473
+ }
474
+ /** Every parameter object of a 2.0 document, before or after its upgrade (shared ones too). */
475
+ function swagger2Params(doc, f) {
476
+ const each = (list) => {
477
+ if (Array.isArray(list))
478
+ for (const p of list)
479
+ if (p && typeof p === "object")
480
+ f(p);
481
+ };
482
+ for (const shared of [doc.parameters, doc.components?.parameters]) {
483
+ if (shared && typeof shared === "object")
484
+ each(Object.values(shared));
485
+ }
486
+ for (const item of Object.values(doc.paths ?? {})) {
487
+ if (!item || typeof item !== "object")
488
+ continue;
489
+ each(item.parameters);
490
+ for (const m of HTTP_METHODS)
491
+ each(item[m]?.parameters);
492
+ }
493
+ }
494
+ /** The most a parsed document may expand to, in characters, counting every alias at each place it is used. */
495
+ const SPEC_MAX_EXPANDED_CHARS = 64_000_000;
496
+ /**
497
+ * A parsed document with no cycles, and no larger than SPEC_MAX_EXPANDED_CHARS
498
+ * once its aliases are expanded.
499
+ *
500
+ * YAML aliases make the parsed value a graph. `&a {self: *a}` is an object
501
+ * that contains itself, which sent the dereferencer into unbounded
502
+ * recursion (a stack overflow, not an error). A value reached again while
503
+ * it is still being copied (a cycle) becomes "(circular)"; one reached
504
+ * again after (an alias used twice) is shared, as the parser shared it, so
505
+ * the copy is linear.
506
+ *
507
+ * Sharing is not enough: the dereferencer and every renderer walk each path
508
+ * through the graph, so `l2: {l: *l1, r: *l1}` doubles per level (a 724-byte
509
+ * spec took 6.8 s to dereference at depth 22, and a `summary` aliased the
510
+ * same way made a 147 MB page). So the copy also counts the expanded size
511
+ * (memoized per node, so counting is linear too) and refuses a document past
512
+ * the bound, as the `yaml` parser does with its alias limit. js-yaml, which
513
+ * dogsbay-ssg parses with, has no such limit. JSON never has either problem.
514
+ */
515
+ function acyclic(root, label) {
516
+ const done = new Map();
517
+ const open = new Set();
518
+ const add = (a, b) => Math.min(SPEC_MAX_EXPANDED_CHARS + 1, a + b);
519
+ const copy = (v, depth) => {
520
+ if (typeof v === "string")
521
+ return { out: v, size: v.length + 2 };
522
+ if (!v || typeof v !== "object" || v instanceof Date)
523
+ return { out: v, size: 8 };
524
+ const finished = done.get(v);
525
+ if (finished)
526
+ return finished;
527
+ if (open.has(v))
528
+ return { out: "(circular)", size: 12 };
529
+ if (depth > 1_000)
530
+ throw new Error(`${label} is nested more than 1,000 levels deep`);
531
+ open.add(v);
532
+ let size = 2;
533
+ let out;
534
+ if (Array.isArray(v)) {
535
+ out = v.map((e) => {
536
+ const c = copy(e, depth + 1);
537
+ size = add(size, c.size + 1);
538
+ return c.out;
539
+ });
540
+ }
541
+ else {
542
+ out = {};
543
+ for (const [k, e] of Object.entries(v)) {
544
+ const c = copy(e, depth + 1);
545
+ size = add(size, c.size + k.length + 4);
546
+ out[k] = c.out;
547
+ }
548
+ }
549
+ open.delete(v);
550
+ const r = { out, size };
551
+ done.set(v, r);
552
+ return r;
553
+ };
554
+ const r = copy(root, 0);
555
+ if (r.size > SPEC_MAX_EXPANDED_CHARS) {
556
+ throw new Error(`${label} expands to more than ${SPEC_MAX_EXPANDED_CHARS.toLocaleString("en")} characters through repeated YAML aliases`);
557
+ }
558
+ return r.out;
559
+ }
560
+ /**
561
+ * Prepared specs by path, each parsed and dereferenced once however many
562
+ * pages embed it. `read` returns the file's text; failures are cached too,
563
+ * so a broken spec is reported per page without being parsed again.
564
+ */
565
+ export function createSpecCache(read, parseYaml) {
566
+ const specs = new Map();
567
+ return {
568
+ get(path) {
569
+ let p = specs.get(path);
570
+ if (!p) {
571
+ p = Promise.resolve()
572
+ .then(() => read(path))
573
+ .then((text) => prepareSpec(parseSpecText(text, parseYaml), path));
574
+ specs.set(path, p);
575
+ }
576
+ return p;
577
+ },
578
+ get size() {
579
+ return specs.size;
580
+ },
581
+ };
582
+ }
583
+ /** A spec field read as text: a YAML alias can put any value where a string belongs. */
584
+ const txt = (v) => (typeof v === "string" ? v : undefined);
585
+ // ─── Values copied from the spec ─────────────────────────────────────
586
+ /**
587
+ * Bounds on the spec values an operation's props carry verbatim: examples,
588
+ * defaults and enums. A YAML anchor repeated through aliases is ONE object
589
+ * in the parsed spec and a full copy in every place a renderer serializes
590
+ * it: a 300-byte spec grew to a 33 MB page and 720 MB of memory in
591
+ * dogsbay-ssg, and an example that refers to itself crashed the build
592
+ * (JSON.stringify on a cycle). Values are copied here, so props are plain,
593
+ * acyclic and bounded, and every renderer is protected.
594
+ */
595
+ const VALUE_MAX_CHARS = 4_000; // one default, enum entry or property example
596
+ const EXAMPLE_MAX_CHARS = 50_000; // a request or response example
597
+ const ENUM_MAX_VALUES = 200;
598
+ const OPERATION_MAX_CHARS = 500_000; // everything copied for one operation
599
+ const VALUE_MAX_DEPTH = 32;
600
+ /** What a value over its bound is shown as. */
601
+ export const VALUE_TOO_LARGE = "(not shown: too large)";
602
+ /** The budget of the operation being collected (collectOperations is synchronous). */
603
+ let operationBudget = OPERATION_MAX_CHARS;
604
+ class TooLarge extends Error {
605
+ }
606
+ /**
607
+ * A plain JSON copy of a spec value within `max` serialized characters (and
608
+ * the operation's remaining budget), or VALUE_TOO_LARGE. A reference back
609
+ * to an enclosing value becomes "(circular)". Counting stops at the bound,
610
+ * so an exponentially aliased value costs no more than the bound to refuse.
611
+ */
612
+ function copyValue(v, max = VALUE_MAX_CHARS) {
613
+ if (v === undefined)
614
+ return undefined;
615
+ const limit = Math.min(max, operationBudget);
616
+ let left = limit;
617
+ const ancestors = new Set();
618
+ const spend = (n) => {
619
+ if ((left -= n) < 0)
620
+ throw new TooLarge();
621
+ };
622
+ const copy = (x, depth) => {
623
+ if (x === null || x === undefined)
624
+ return spend(4), null;
625
+ if (typeof x === "string")
626
+ return spend(x.length + 2), x;
627
+ if (typeof x === "number" || typeof x === "boolean")
628
+ return spend(String(x).length), x;
629
+ if (x instanceof Date)
630
+ return spend(26), Number.isNaN(x.getTime()) ? String(x) : x.toISOString();
631
+ if (typeof x !== "object")
632
+ return spend(String(x).length + 2), String(x);
633
+ if (ancestors.has(x))
634
+ return spend(10), "(circular)";
635
+ if (depth >= VALUE_MAX_DEPTH)
636
+ throw new TooLarge();
637
+ ancestors.add(x);
638
+ try {
639
+ if (Array.isArray(x))
640
+ return spend(2), x.map((e) => (spend(1), copy(e, depth + 1)));
641
+ const out = {};
642
+ spend(2);
643
+ for (const [k, e] of Object.entries(x)) {
644
+ spend(k.length + 4);
645
+ out[k] = copy(e, depth + 1);
646
+ }
647
+ return out;
648
+ }
649
+ finally {
650
+ ancestors.delete(x);
651
+ }
652
+ };
653
+ try {
654
+ const out = copy(v, 0);
655
+ operationBudget -= limit - left;
656
+ return out;
657
+ }
658
+ catch (err) {
659
+ if (!(err instanceof TooLarge))
660
+ throw err;
661
+ operationBudget -= Math.min(operationBudget, VALUE_TOO_LARGE.length);
662
+ return VALUE_TOO_LARGE;
663
+ }
664
+ }
665
+ /** An enum's values, each copied with copyValue, at most ENUM_MAX_VALUES; a cut list ends in "…". */
666
+ function copyEnum(list) {
667
+ if (!Array.isArray(list))
668
+ return undefined;
669
+ const out = [];
670
+ for (const v of list) {
671
+ if (out.length >= ENUM_MAX_VALUES || operationBudget <= 0)
672
+ return [...out, "…"];
673
+ out.push(copyValue(v));
674
+ }
675
+ return out;
676
+ }
677
+ /**
678
+ * The first server's URL, without a trailing slash: operation paths start
679
+ * with one. A Swagger 2.0 `basePath: /` upgrades to a server `/`, and joining
680
+ * it gave `//api/v2/…` in the URL bar and every code sample.
681
+ */
682
+ function serverUrl(spec) {
683
+ return (txt(spec.servers?.[0]?.url) ?? "").replace(/\/+$/, "");
684
+ }
685
+ /** Every operation in the spec, in document order, as `endpoint` props. */
686
+ function collectOperations(spec) {
687
+ const out = [];
688
+ const baseUrl = serverUrl(spec);
689
+ for (const [path, pathItem] of Object.entries(spec.paths || {})) {
690
+ if (!pathItem)
691
+ continue;
692
+ for (const method of HTTP_METHODS) {
693
+ const op = buildOperation(path, pathItem, method, baseUrl);
694
+ if (op)
695
+ out.push(op);
696
+ }
697
+ }
698
+ return out;
699
+ }
700
+ /** One operation as `endpoint` props, or undefined when the path item has none for `method`. */
701
+ function buildOperation(path, pathItem, method, baseUrl) {
702
+ const operation = pathItem?.[method];
703
+ if (!operation)
704
+ return undefined;
705
+ operationBudget = OPERATION_MAX_CHARS;
706
+ const opTags = Array.isArray(operation.tags) ? operation.tags.filter((t) => typeof t === "string") : [];
707
+ const { summary, description } = summaryAndDescription(txt(operation.summary), txt(operation.description));
708
+ const props = {
709
+ method,
710
+ path,
711
+ baseUrl,
712
+ summary,
713
+ description,
714
+ operationId: txt(operation.operationId) || undefined,
715
+ tags: opTags.length > 0 ? opTags : undefined,
716
+ deprecated: operation.deprecated === true ? true : undefined,
717
+ parameters: extractParameters(operation, pathItem),
718
+ ...extractRequestBody(operation),
719
+ responses: extractResponses(operation),
720
+ codeSamples: resolveCodeSamples(operation, method, path, baseUrl),
721
+ };
722
+ return { method, path, operation, props };
723
+ }
724
+ /** Shortest summary read as a wrapped first line (drf-yasg's in AAP: 57-84 characters). */
725
+ const SUMMARY_WRAP_MIN = 40;
726
+ /**
727
+ * The summary and description, with a sentence the generator broke in two
728
+ * put back together. drf-yasg makes the summary from a docstring's first
729
+ * LINE and the description from the rest, so a sentence that wraps reads
730
+ * "…retrieve the list of" + a paragraph "activity streams.". A summary with
731
+ * no closing punctuation, followed by a description that starts in lower
732
+ * case, takes the description's first paragraph when that ends the
733
+ * sentence and the summary is long enough to be a wrapped line. Only plain text is moved:
734
+ * the summary is shown as text, so Markdown in the continuation would show
735
+ * its syntax.
736
+ */
737
+ export function summaryAndDescription(summary, description) {
738
+ const s = summary?.trim();
739
+ // Blank lines only: an indented first line is a code block, not prose.
740
+ const d = description?.replace(/^(?:[ \t]*\r?\n)+/, "");
741
+ // A wrapped docstring line is long; a hand-written "Get user" is not, and
742
+ // "returns the user." after it is a description, not its continuation.
743
+ if (s && d && s.length >= SUMMARY_WRAP_MIN && !/[.!?:;)\]"'`]$/.test(s) && /^\p{Ll}/u.test(d)) {
744
+ const end = d.search(/\n\s*\n/);
745
+ const first = (end < 0 ? d : d.slice(0, end)).replace(/\s+/g, " ").trim();
746
+ // The continuation must finish the sentence. An underscore inside a word
747
+ // (`job_template`) is not emphasis.
748
+ if (/[.!?:]$/.test(first) && !/[`*[\]<>\\#|~]|(?<!\w)_|_(?!\w)/.test(first)) {
749
+ const rest = end < 0 ? "" : d.slice(end).trim();
750
+ return { summary: `${s} ${first}`, description: rest || undefined };
751
+ }
752
+ }
753
+ return { summary: summary || undefined, description: description || undefined };
754
+ }
755
+ /**
756
+ * The operation a `:::openapi` directive selects from its props, or why it
757
+ * cannot: `path` and `method` come together or not at all.
758
+ */
759
+ export function directiveOperation(props) {
760
+ const path = typeof props?.path === "string" ? props.path.trim() : "";
761
+ const method = typeof props?.method === "string" ? props.method.trim().toLowerCase() : "";
762
+ if (!path && !method)
763
+ return {};
764
+ if (!path || !method)
765
+ return { error: "`path` and `method` select an operation together; give both or neither" };
766
+ if (!HTTP_METHODS.includes(method))
767
+ return { error: `not an HTTP method: ${method}` };
768
+ return { operation: { path, method } };
769
+ }
770
+ /**
771
+ * A whole spec as the BODY of one page: what Swagger UI or Redoc show when a
772
+ * page embeds them. The info version and description, then a section per tag
773
+ * (declared order, then first use; untagged operations last, under "Other"),
774
+ * each holding its operations as `endpoint` nodes in document order.
775
+ *
776
+ * `spec` is a dereferenced spec (`prepareSpec`), and is not modified: the
777
+ * nodes are copies, so one parsed spec serves every page that embeds it.
778
+ *
779
+ * `specToPages` is the other shape — a page per operation — for a spec
780
+ * that IS the source. This one is for a spec a prose page points at.
781
+ */
782
+ export function embedSpec(spec, options = {}) {
783
+ if (options.operation) {
784
+ const { path, method } = options.operation;
785
+ // Only this operation is built: building them all to keep one made a page per operation
786
+ // quadratic in the spec (400 pages over a 400-operation spec: 4 s, in dogsbay-ssg).
787
+ const m = method.toLowerCase();
788
+ const paths = spec.paths && typeof spec.paths === "object" ? spec.paths : {};
789
+ const op = HTTP_METHODS.includes(m) && Object.hasOwn(paths, path) ? buildOperation(path, paths[path], m, serverUrl(spec)) : undefined;
790
+ if (!op)
791
+ throw new Error(`the spec has no operation ${method.toUpperCase()} ${path}`);
792
+ return { tree: [{ type: "endpoint", props: structuredClone(op.props) }], headings: [], operations: 1 };
793
+ }
794
+ const level = Math.min(6, Math.max(2, options.level ?? 2));
795
+ const tree = [];
796
+ const headings = [];
797
+ const used = new Set(options.usedIds ?? []);
798
+ // A description's own headings go under the section that holds it, with
799
+ // ids, and into the outline (a `# Title` was a second H1).
800
+ const describe = (text, minLevel) => {
801
+ const d = descriptionHtml(text, minLevel, used);
802
+ headings.push(...d.headings);
803
+ tree.push({ type: "prose", html: d.html });
804
+ };
805
+ const info = spec.info || {};
806
+ if (txt(info.version) || typeof info.version === "number") {
807
+ tree.push(versionLine(String(info.version)));
808
+ }
809
+ if (typeof info.description === "string" && info.description.trim()) {
810
+ describe(info.description, level);
811
+ }
812
+ // The same groups as a page per operation (specToPages): tags, very large
813
+ // ones split by resource, ungrouped operations last under "Other".
814
+ const operations = collectOperations(spec);
815
+ const { groups, ungrouped } = groupOperations(spec, operations);
816
+ const sections = [...groups, ...(ungrouped.length ? [{ label: "Other", description: "", ops: ungrouped }] : [])];
817
+ for (const { label, description, ops } of sections) {
818
+ const id = uniqueId(slugifyTag(label) || "section", used);
819
+ tree.push({
820
+ type: "heading",
821
+ props: { level, id },
822
+ inline: [{ type: "text", text: label }],
823
+ });
824
+ headings.push({ depth: level, slug: id, text: label });
825
+ if (description)
826
+ describe(description, level + 1);
827
+ // A copy: enum, default and example values are the spec's own objects, and the spec is shared.
828
+ for (const op of ops)
829
+ tree.push({ type: "endpoint", props: structuredClone(op.props) });
830
+ }
831
+ return { tree, headings, operations: operations.length };
832
+ }
833
+ function uniqueId(base, used) {
834
+ let id = base;
835
+ for (let n = 2; used.has(id); n++)
836
+ id = `${base}-${n}`;
837
+ used.add(id);
838
+ return id;
839
+ }
840
+ /**
841
+ * A description (CommonMark) → HTML with its headings shifted so the
842
+ * highest is `minLevel`, each given a unique id, and listed for the TOC.
843
+ * Raw HTML stays off (see `md`).
844
+ */
845
+ function descriptionHtml(text, minLevel, used) {
846
+ const tokens = md.parse(text, {});
847
+ const levels = tokens.filter((t) => t.type === "heading_open").map((t) => Number(t.tag.slice(1)));
848
+ const headings = [];
849
+ if (levels.length) {
850
+ const shift = Math.max(0, minLevel - Math.min(...levels));
851
+ for (let i = 0; i < tokens.length; i++) {
852
+ const t = tokens[i];
853
+ if (t.type !== "heading_open")
854
+ continue;
855
+ const depth = Math.min(6, Number(t.tag.slice(1)) + shift);
856
+ const inline = tokens[i + 1];
857
+ const label = (inline?.children ?? [])
858
+ .filter((c) => c.type === "text" || c.type === "code_inline")
859
+ .map((c) => c.content)
860
+ .join("")
861
+ .trim();
862
+ const id = uniqueId(slugifyTag(label) || "section", used);
863
+ t.tag = `h${depth}`;
864
+ t.attrSet("id", id);
865
+ if (tokens[i + 2]?.type === "heading_close")
866
+ tokens[i + 2].tag = `h${depth}`;
867
+ headings.push({ depth, slug: id, text: label });
868
+ }
869
+ }
870
+ return { html: md.renderer.render(tokens, md.options, {}).trim(), headings };
871
+ }
872
+ function normalisePrefix(p) {
873
+ if (!p)
874
+ return "";
875
+ return p.replace(/\/+$/, "");
876
+ }
877
+ /** The one non-null type of a schema: OpenAPI 3.1 (and an upgraded 2.0
878
+ * spec) writes a nullable object as `type: [object, "null"]`. */
879
+ function primaryType(schema) {
880
+ if (!schema)
881
+ return undefined;
882
+ if (!Array.isArray(schema.type))
883
+ return schema.type;
884
+ const types = schema.type.filter((t) => t !== "null");
885
+ return types.length === 1 ? types[0] : undefined;
886
+ }
887
+ function schemaToType(schema, depth = 0) {
888
+ if (!schema)
889
+ return "any";
890
+ // An array of itself (a cycle once dereferenced) has no finite name.
891
+ if (depth > SCHEMA_MAX_DEPTH)
892
+ return "any";
893
+ // OpenAPI 3.1 (and a Swagger 2.0 spec upgraded to it) writes nullable as
894
+ // a type list: `type: [string, "null"]`.
895
+ if (Array.isArray(schema.type)) {
896
+ const types = schema.type.filter((t) => t !== "null");
897
+ if (types.length === 1)
898
+ return schemaToType({ ...schema, type: types[0] }, depth + 1);
899
+ return types.length ? types.join(" | ") : "null";
900
+ }
901
+ if (schema.type === "array") {
902
+ const itemType = schema.items ? schemaToType(schema.items, depth + 1) : "any";
903
+ return `${itemType}[]`;
904
+ }
905
+ return schema.type || "any";
906
+ }
907
+ /**
908
+ * Bounds on walking one schema. A dereferenced spec is a GRAPH: a
909
+ * self-referencing schema is a cycle (the walk overflowed the stack), and
910
+ * eight properties that each reference the level below grow 8× per level
911
+ * (a 2.2 KB spec made 18 MB of tree at depth 6). Past a bound the walk stops
912
+ * and the property shows without children.
913
+ */
914
+ const SCHEMA_MAX_DEPTH = 10;
915
+ const SCHEMA_MAX_NODES = 2000;
916
+ const newWalk = () => ({ nodes: SCHEMA_MAX_NODES, depth: 0, ancestors: new Set() });
917
+ /** Run `f` one level into `schema`, or return `empty` past a bound. */
918
+ function descend(walk, schema, empty, f) {
919
+ if (!schema || typeof schema !== "object")
920
+ return f();
921
+ if (walk.depth >= SCHEMA_MAX_DEPTH || walk.nodes <= 0 || walk.ancestors.has(schema))
922
+ return empty;
923
+ walk.depth++;
924
+ walk.ancestors.add(schema);
925
+ try {
926
+ return f();
927
+ }
928
+ finally {
929
+ walk.depth--;
930
+ walk.ancestors.delete(schema);
931
+ }
932
+ }
933
+ function extractSchemaProperties(schema, requiredFields, walk = newWalk()) {
934
+ if (!schema?.properties)
935
+ return [];
936
+ return descend(walk, schema, [], () => propertiesOf(schema, requiredFields, walk));
937
+ }
938
+ function propertiesOf(schema, requiredFields, walk) {
939
+ const required = requiredFields || schema.required || [];
940
+ const out = [];
941
+ for (const [name, prop] of Object.entries(schema.properties)) {
942
+ if (walk.nodes-- <= 0)
943
+ break;
944
+ out.push(propertyOf(name, prop ?? {}, required, walk));
945
+ }
946
+ return out;
947
+ }
948
+ function propertyOf(name, prop, required, walk) {
949
+ const result = {
950
+ name,
951
+ type: schemaToType(prop),
952
+ required: required.includes(name),
953
+ };
954
+ if (txt(prop.description))
955
+ result.description = prop.description;
956
+ if (prop.nullable)
957
+ result.nullable = true;
958
+ if (txt(prop.format))
959
+ result.format = prop.format;
960
+ if (prop.enum)
961
+ result.enum = copyEnum(prop.enum);
962
+ if (prop.default !== undefined)
963
+ result.default = copyValue(prop.default);
964
+ const example = schemaExample(prop);
965
+ if (example !== undefined)
966
+ result.example = copyValue(example);
967
+ if (primaryType(prop) === "object" && prop.properties) {
968
+ result.properties = extractSchemaProperties(prop, undefined, walk);
969
+ }
970
+ if (primaryType(prop) === "array" && prop.items) {
971
+ if (primaryType(prop.items) === "object" && prop.items.properties) {
972
+ result.items = extractSchemaProperties(prop.items, undefined, walk);
973
+ }
974
+ }
975
+ return result;
976
+ }
977
+ function extractParameters(operation, pathItem) {
978
+ const params = [];
979
+ const allParams = [
980
+ ...(pathItem.parameters || []),
981
+ ...(operation.parameters || []),
982
+ ];
983
+ // Operation-level wins over path-level on (name, in) collision.
984
+ const seen = new Map();
985
+ for (const p of allParams) {
986
+ if (!txt(p?.name) || !txt(p?.in))
987
+ continue;
988
+ seen.set(`${p.name}:${p.in}`, p);
989
+ }
990
+ for (const p of seen.values()) {
991
+ // A Swagger 2.0 parameter keeps its type on itself, not in `schema`; the
992
+ // upgrade to 3.1 leaves PATH-ITEM-level parameters that way (drf-yasg's
993
+ // `{version}`/`{id}` showed type "any" where Swagger UI says "string").
994
+ const schema = p.schema ?? (txt(p.type) ? { type: p.type, format: p.format, enum: p.enum, default: p.default, items: p.items } : undefined);
995
+ const param = {
996
+ name: p.name,
997
+ in: p.in,
998
+ type: schemaToType(schema),
999
+ required: p.required || false,
1000
+ };
1001
+ if (txt(p.description))
1002
+ param.description = p.description;
1003
+ if (schema?.default !== undefined) {
1004
+ const d = copyValue(schema.default);
1005
+ param.default = typeof d === "string" ? d : JSON.stringify(d);
1006
+ }
1007
+ if (schema?.enum)
1008
+ param.enum = copyEnum(schema.enum);
1009
+ if (p.deprecated)
1010
+ param.deprecated = true;
1011
+ params.push(param);
1012
+ }
1013
+ return params;
1014
+ }
1015
+ function extractRequestBody(operation) {
1016
+ const body = operation.requestBody;
1017
+ if (!body?.content)
1018
+ return {};
1019
+ const contentType = body.content["application/json"] ? "application/json" : Object.keys(body.content)[0];
1020
+ const mediaType = body.content[contentType];
1021
+ if (!mediaType?.schema)
1022
+ return {};
1023
+ return {
1024
+ requestBody: extractSchemaProperties(mediaType.schema),
1025
+ requestBodyExample: copyValue(mediaExample(mediaType, contentType) ?? generateExampleFromSchema(mediaType.schema), EXAMPLE_MAX_CHARS),
1026
+ };
1027
+ }
1028
+ function extractResponses(operation) {
1029
+ if (!operation.responses)
1030
+ return [];
1031
+ return Object.entries(operation.responses)
1032
+ .filter(([status]) => status !== "default")
1033
+ .map(([status, resp]) => {
1034
+ const result = {
1035
+ status,
1036
+ description: txt(resp.description),
1037
+ };
1038
+ if (resp.content) {
1039
+ const contentTypeKey = resp.content["application/json"]
1040
+ ? "application/json"
1041
+ : Object.keys(resp.content)[0];
1042
+ const mediaType = resp.content[contentTypeKey];
1043
+ if (contentTypeKey)
1044
+ result.contentType = contentTypeKey;
1045
+ if (mediaType?.schema) {
1046
+ const rawSchema = mediaType.schema;
1047
+ const schema = primaryType(rawSchema) === "array" && rawSchema.items
1048
+ ? rawSchema.items
1049
+ : rawSchema;
1050
+ result.schema = extractSchemaProperties(schema);
1051
+ result.example = copyValue(mediaExample(mediaType, contentTypeKey) ?? generateExampleFromSchema(rawSchema), EXAMPLE_MAX_CHARS);
1052
+ }
1053
+ else {
1054
+ // An example with no schema (drf-yasg's `GET /api/v2/` lists its resources this way).
1055
+ const own = mediaExample(mediaType, contentTypeKey);
1056
+ if (own !== undefined)
1057
+ result.example = copyValue(own, EXAMPLE_MAX_CHARS);
1058
+ }
1059
+ }
1060
+ return result;
1061
+ })
1062
+ .sort((a, b) => a.status.localeCompare(b.status));
1063
+ }
1064
+ function generateExampleFromSchema(schema, walk = newWalk()) {
1065
+ if (!schema)
1066
+ return undefined;
1067
+ if (walk.nodes-- <= 0)
1068
+ return null;
1069
+ return descend(walk, schema, null, () => exampleOf(schema, walk));
1070
+ }
1071
+ /**
1072
+ * A schema's own example. OpenAPI 3.0 writes `example: x`; 3.1 (JSON Schema)
1073
+ * writes `examples: [x]`, and prepareSpec upgrades every spec to 3.1, so a
1074
+ * Swagger 2.0 or 3.0 example arrives in the second form. Reading only the
1075
+ * first put `null` where drf-yasg's request body examples were.
1076
+ */
1077
+ function schemaExample(schema) {
1078
+ if (schema?.example !== undefined)
1079
+ return schema.example;
1080
+ return Array.isArray(schema?.examples) && schema.examples.length > 0 ? schema.examples[0] : undefined;
1081
+ }
1082
+ /**
1083
+ * A media type's own example: `example`, or the first of `examples` (a map
1084
+ * of `{ value }`). JSON media types only: the example panel is JSON, and an
1085
+ * XML or text example would print as a quoted string.
1086
+ */
1087
+ function mediaExample(mediaType, contentType) {
1088
+ if (!contentType || !/(^application\/json|\+json)(;|$)/i.test(contentType))
1089
+ return undefined;
1090
+ if (mediaType?.example !== undefined)
1091
+ return mediaType.example;
1092
+ const first = mediaType?.examples && typeof mediaType.examples === "object" ? Object.values(mediaType.examples)[0] : undefined;
1093
+ return first && typeof first === "object" && "value" in first ? first.value : undefined;
1094
+ }
1095
+ function exampleOf(schema, walk) {
1096
+ const own = schemaExample(schema);
1097
+ if (own !== undefined)
1098
+ return own;
1099
+ switch (primaryType(schema)) {
1100
+ case "object": {
1101
+ if (!schema.properties)
1102
+ return {};
1103
+ const obj = {};
1104
+ for (const [name, prop] of Object.entries(schema.properties)) {
1105
+ obj[name] = generateExampleFromSchema(prop, walk);
1106
+ }
1107
+ return obj;
1108
+ }
1109
+ case "array":
1110
+ return schema.items ? [generateExampleFromSchema(schema.items, walk)] : [];
1111
+ case "string": {
1112
+ if (schema.enum)
1113
+ return schema.enum[0];
1114
+ if (schema.format === "date-time")
1115
+ return "2024-01-01T00:00:00Z";
1116
+ if (schema.format === "email")
1117
+ return "user@example.com";
1118
+ if (schema.format === "uri" || schema.format === "url") {
1119
+ return "https://example.com";
1120
+ }
1121
+ if (schema.format === "uuid") {
1122
+ return "550e8400-e29b-41d4-a716-446655440000";
1123
+ }
1124
+ if (schema.default !== undefined)
1125
+ return schema.default;
1126
+ return "string";
1127
+ }
1128
+ case "integer":
1129
+ return schema.default ?? 0;
1130
+ case "number":
1131
+ return schema.default ?? 0.0;
1132
+ case "boolean":
1133
+ return schema.default ?? true;
1134
+ default:
1135
+ return null;
1136
+ }
1137
+ }
1138
+ function buildTagPage(tagName, tagSlug, description, ops) {
1139
+ const tree = [
1140
+ {
1141
+ type: "heading",
1142
+ props: { level: 1, id: tagSlug },
1143
+ inline: [{ type: "text", text: tagName }],
1144
+ },
1145
+ ];
1146
+ const used = new Set([tagSlug]);
1147
+ const described = description ? descriptionHtml(description, 2, used) : null;
1148
+ if (described)
1149
+ tree.push({ type: "prose", html: described.html });
1150
+ // One line per operation, as Swagger UI lists a tag: the method as a
1151
+ // coloured badge, the path linking to its page, and the summary when the
1152
+ // spec gives it one of its own. Built from badge, link and code nodes, so
1153
+ // every exporter has a case for it (Obsidian, MkDocs: a plain label).
1154
+ //
1155
+ // Site-root hrefs, as an importer writes them, so the build adds the base
1156
+ // path and the source's /<name>/ prefix like any other link. These were
1157
+ // relative `<a href="pets/list-pets">` in raw HTML: on the tag page,
1158
+ // served at /pets/, the browser read them as /pets/pets/list-pets.
1159
+ const items = [...ops].sort(byPathThenMethod).map((op) => ({
1160
+ type: "list-item",
1161
+ inline: [
1162
+ { type: "badge", text: op.method.toUpperCase(), variant: METHOD_BADGE[op.method] ?? "secondary" },
1163
+ { type: "text", text: " " },
1164
+ { type: "link", href: `/${op.slug}`, children: [{ type: "code", text: op.path }] },
1165
+ ...(op.summary ? [{ type: "text", text: ` — ${op.summary}` }] : []),
1166
+ ...(op.deprecated ? [{ type: "text", text: " (deprecated)" }] : []),
1167
+ ],
1168
+ }));
1169
+ tree.push({ type: "unordered-list", children: items });
1170
+ return {
1171
+ slug: tagSlug,
1172
+ title: tagName,
1173
+ tree,
1174
+ headings: [{ depth: 1, slug: tagSlug, text: tagName }, ...(described?.headings ?? [])],
1175
+ };
1176
+ }
1177
+ function buildInfoPage(title, description, version) {
1178
+ const tree = [
1179
+ {
1180
+ type: "heading",
1181
+ props: { level: 1, id: "api-reference" },
1182
+ inline: [{ type: "text", text: title }],
1183
+ },
1184
+ ];
1185
+ if (version) {
1186
+ tree.push(versionLine(version));
1187
+ }
1188
+ const described = description ? descriptionHtml(description, 2, new Set(["api-reference"])) : null;
1189
+ if (described)
1190
+ tree.push({ type: "prose", html: described.html });
1191
+ return {
1192
+ slug: "index",
1193
+ title,
1194
+ tree,
1195
+ headings: [{ depth: 1, slug: "api-reference", text: title }, ...(described?.headings ?? [])],
1196
+ };
1197
+ }
1198
+ const METHOD_ORDER = ["get", "post", "put", "patch", "delete", "head", "options"];
1199
+ /** A method's badge variant on a group page: the Badge status colour nearest the URL bar's (--api-get green, --api-post blue), contrast-tested. */
1200
+ const METHOD_BADGE = { get: "success", post: "info", put: "warning", patch: "warning", delete: "danger" };
1201
+ /** By path, then GET, POST, PUT, PATCH, DELETE: a resource's operations together. */
1202
+ function byPathThenMethod(a, b) {
1203
+ if (a.path !== b.path)
1204
+ return a.path < b.path ? -1 : 1;
1205
+ return METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method);
1206
+ }
1207
+ function hrefHref(prefix, slug) {
1208
+ const p = prefix ? `${prefix}/` : "/";
1209
+ return slug === "index" ? p : `${p}${slug}`;
1210
+ }
1211
+ /**
1212
+ * A spec description (CommonMark, per OpenAPI) → HTML, raw HTML off: a `<`
1213
+ * in the spec is escaped, and markdown-it refuses `javascript:` links.
1214
+ * Plain-text paragraphs showed a spec's `## Authentication` literally.
1215
+ */
1216
+ const md = new MarkdownIt({ html: false, linkify: false });
1217
+ /**
1218
+ * The spec's version as a badge, not raw HTML: a sanitizing renderer drops a
1219
+ * raw `<p class>`'s class, so every spec warned `removed p[class]` and lost
1220
+ * the styling (#103). A badge renders the same in every target.
1221
+ */
1222
+ function versionLine(version) {
1223
+ return { type: "paragraph", inline: [{ type: "badge", text: `Version ${version}`, variant: "outline" }] };
1224
+ }