@dogsbay/format-openapi 0.2.0-beta.12 → 0.2.0-beta.120
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/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/code-samples.js +10 -0
- package/dist/code-samples.js.map +1 -1
- package/dist/core.d.ts +97 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +930 -0
- package/dist/core.js.map +1 -0
- package/dist/embed.d.ts +30 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +135 -0
- package/dist/embed.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/parse.d.ts +10 -6
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +87 -423
- package/dist/parse.js.map +1 -1
- package/dist/spec-path.d.ts +34 -0
- package/dist/spec-path.d.ts.map +1 -0
- package/dist/spec-path.js +69 -0
- package/dist/spec-path.js.map +1 -0
- package/package.json +23 -8
package/dist/core.js
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
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 pages = [];
|
|
36
|
+
// Collect tag descriptions (from spec.tags[]) for tag overview pages.
|
|
37
|
+
const tagDescriptions = new Map();
|
|
38
|
+
for (const t of spec.tags || []) {
|
|
39
|
+
if (txt(t?.name))
|
|
40
|
+
tagDescriptions.set(t.name, txt(t.description) ?? "");
|
|
41
|
+
}
|
|
42
|
+
const tagToOps = new Map();
|
|
43
|
+
const untagged = [];
|
|
44
|
+
const operations = collectOperations(spec);
|
|
45
|
+
const slugs = pageSlugs(spec, operations, options.reservedSlugs);
|
|
46
|
+
for (const op of operations) {
|
|
47
|
+
const { method, path, operation, props } = op;
|
|
48
|
+
const primaryTag = props.tags?.[0];
|
|
49
|
+
const tagSlug = primaryTag ? slugs.tags.get(primaryTag) : "";
|
|
50
|
+
const opSlug = slugs.operations.get(op);
|
|
51
|
+
const slug = tagSlug ? `${tagSlug}/${opSlug}` : opSlug;
|
|
52
|
+
const title = txt(operation.summary) || `${method.toUpperCase()} ${path}`;
|
|
53
|
+
const tree = [
|
|
54
|
+
{
|
|
55
|
+
type: "endpoint",
|
|
56
|
+
props: structuredClone(props),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
const headings = [{ depth: 1, slug: opSlug, text: title }];
|
|
60
|
+
pages.push({
|
|
61
|
+
slug,
|
|
62
|
+
title,
|
|
63
|
+
tree,
|
|
64
|
+
headings,
|
|
65
|
+
});
|
|
66
|
+
const summary = {
|
|
67
|
+
slug,
|
|
68
|
+
title,
|
|
69
|
+
method,
|
|
70
|
+
path,
|
|
71
|
+
deprecated: operation.deprecated === true,
|
|
72
|
+
};
|
|
73
|
+
if (primaryTag) {
|
|
74
|
+
const arr = tagToOps.get(primaryTag) || [];
|
|
75
|
+
arr.push(summary);
|
|
76
|
+
tagToOps.set(primaryTag, arr);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
untagged.push(summary);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Tag overview pages.
|
|
83
|
+
for (const [tagName, ops] of tagToOps) {
|
|
84
|
+
const tagSlug = slugs.tags.get(tagName);
|
|
85
|
+
const description = tagDescriptions.get(tagName) || "";
|
|
86
|
+
pages.push(buildTagPage(tagName, tagSlug, description, ops));
|
|
87
|
+
}
|
|
88
|
+
// Root info page.
|
|
89
|
+
const info = spec.info || {};
|
|
90
|
+
pages.push(buildInfoPage(txt(info.title) || "API Reference", txt(info.description) ?? "", txt(info.version) ?? (typeof info.version === "number" ? String(info.version) : "")));
|
|
91
|
+
// Build nav: tag groups in declaration order, with operations as
|
|
92
|
+
// children. Untagged operations land under a synthetic "Other" group
|
|
93
|
+
// so they're discoverable but visually separated.
|
|
94
|
+
const nav = buildNav(spec, pages, tagToOps, untagged, hrefPrefix, slugs.tags);
|
|
95
|
+
return { pages, nav };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every page's URL, distinct. The root page is `index`, a tag's overview is
|
|
99
|
+
* `<tag>`, a tagged operation `<tag>/<operation>` and an untagged one
|
|
100
|
+
* `<operation>`, where `<operation>` comes from the operationId, or else the
|
|
101
|
+
* method and path. Slugs that would land on a URL already taken (`getPet` and
|
|
102
|
+
* `get_pet`; an operationId `index`; an untagged operation named like a tag;
|
|
103
|
+
* two tags that slugify alike) are made distinct: an operation falls back to
|
|
104
|
+
* its method and path, then a numeric suffix, in spec order, so the first
|
|
105
|
+
* claimant keeps the plain slug. Before, the later page silently replaced the
|
|
106
|
+
* earlier one.
|
|
107
|
+
*/
|
|
108
|
+
function pageSlugs(spec, operations, reserved = []) {
|
|
109
|
+
const tags = new Map();
|
|
110
|
+
const rootTaken = new Set(["index", ...reserved.map((r) => r.toLowerCase())]);
|
|
111
|
+
// Tags that have pages, the spec's declared order first (as in the nav).
|
|
112
|
+
const used = new Set(operations.map((op) => op.props.tags?.[0]).filter(Boolean));
|
|
113
|
+
const tagNames = [];
|
|
114
|
+
for (const t of spec.tags || [])
|
|
115
|
+
if (used.has(txt(t?.name)))
|
|
116
|
+
tagNames.push(t.name);
|
|
117
|
+
for (const op of operations)
|
|
118
|
+
if (op.props.tags?.[0])
|
|
119
|
+
tagNames.push(op.props.tags[0]);
|
|
120
|
+
for (const name of tagNames) {
|
|
121
|
+
if (tags.has(name))
|
|
122
|
+
continue;
|
|
123
|
+
const slug = claim(slugifyTag(name) || "tag", rootTaken);
|
|
124
|
+
tags.set(name, slug);
|
|
125
|
+
}
|
|
126
|
+
const folders = new Map();
|
|
127
|
+
const taken = (op) => {
|
|
128
|
+
const primary = op.props.tags?.[0];
|
|
129
|
+
if (!primary)
|
|
130
|
+
return rootTaken;
|
|
131
|
+
const folder = tags.get(primary);
|
|
132
|
+
let set = folders.get(folder);
|
|
133
|
+
if (!set)
|
|
134
|
+
folders.set(folder, (set = new Set(["index"])));
|
|
135
|
+
return set;
|
|
136
|
+
};
|
|
137
|
+
// Two passes: every operation's own slug first, so a fallback never takes
|
|
138
|
+
// the slug a LATER operation would have had (moving a URL that had no
|
|
139
|
+
// collision); then the operations that lost theirs.
|
|
140
|
+
const out = new Map();
|
|
141
|
+
for (const op of operations) {
|
|
142
|
+
const preferred = operationToSlug(op.method, op.path, txt(op.operation.operationId));
|
|
143
|
+
const set = taken(op);
|
|
144
|
+
if (preferred && !set.has(preferred)) {
|
|
145
|
+
set.add(preferred);
|
|
146
|
+
out.set(op, preferred);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const op of operations) {
|
|
150
|
+
if (out.has(op))
|
|
151
|
+
continue;
|
|
152
|
+
// Letters, digits and dashes only: a path's `:`, `?` or `%2F` must not
|
|
153
|
+
// reach a URL this function invents.
|
|
154
|
+
const fallback = operationToSlug(op.method, op.path).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || op.method;
|
|
155
|
+
out.set(op, claim(fallback, taken(op)));
|
|
156
|
+
}
|
|
157
|
+
return { tags, operations: out };
|
|
158
|
+
}
|
|
159
|
+
/** `base`, or `base-2`, `base-3`… : the first not in `used`, which it joins. */
|
|
160
|
+
function claim(base, used) {
|
|
161
|
+
let slug = base;
|
|
162
|
+
for (let n = 2; used.has(slug); n++)
|
|
163
|
+
slug = `${base}-${n}`;
|
|
164
|
+
used.add(slug);
|
|
165
|
+
return slug;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Spec text → object. JSON is read natively; YAML needs a parser passed in
|
|
169
|
+
* (`yaml`'s `parse`, js-yaml's `load`), so a host that has one does not
|
|
170
|
+
* bundle a second.
|
|
171
|
+
*/
|
|
172
|
+
export function parseSpecText(text, parseYaml) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(text);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (!parseYaml)
|
|
178
|
+
throw new Error(`not JSON, and no YAML parser was given (${err.message})`);
|
|
179
|
+
return parseYaml(text);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A parsed spec → the dereferenced OpenAPI 3 document every function here
|
|
184
|
+
* reads. Swagger 2.0 is upgraded to OpenAPI 3 first: everything below reads
|
|
185
|
+
* the 3.x shape (`requestBody`, response `content`, `servers`), so a 2.0
|
|
186
|
+
* spec parsed without the upgrade lost every body parameter and response
|
|
187
|
+
* schema while still producing plausible-looking pages. Canonical's Anbox
|
|
188
|
+
* Cloud API specs are 2.0.
|
|
189
|
+
*/
|
|
190
|
+
export async function prepareSpec(parsed, label = "the spec") {
|
|
191
|
+
if (!parsed || typeof parsed !== "object")
|
|
192
|
+
throw new Error(`${label} is not an OpenAPI document`);
|
|
193
|
+
let doc = acyclic(parsed, label);
|
|
194
|
+
if (typeof doc.swagger === "string" && doc.swagger.startsWith("2")) {
|
|
195
|
+
doc = upgrade(doc).specification;
|
|
196
|
+
}
|
|
197
|
+
const { schema: spec } = await dereference(doc);
|
|
198
|
+
if (!spec)
|
|
199
|
+
throw new Error(`Failed to dereference OpenAPI spec at ${label}`);
|
|
200
|
+
return spec;
|
|
201
|
+
}
|
|
202
|
+
/** The most a parsed document may expand to, in characters, counting every alias at each place it is used. */
|
|
203
|
+
const SPEC_MAX_EXPANDED_CHARS = 64_000_000;
|
|
204
|
+
/**
|
|
205
|
+
* A parsed document with no cycles, and no larger than SPEC_MAX_EXPANDED_CHARS
|
|
206
|
+
* once its aliases are expanded.
|
|
207
|
+
*
|
|
208
|
+
* YAML aliases make the parsed value a graph. `&a {self: *a}` is an object
|
|
209
|
+
* that contains itself, which sent the dereferencer into unbounded
|
|
210
|
+
* recursion (a stack overflow, not an error). A value reached again while
|
|
211
|
+
* it is still being copied (a cycle) becomes "(circular)"; one reached
|
|
212
|
+
* again after (an alias used twice) is shared, as the parser shared it, so
|
|
213
|
+
* the copy is linear.
|
|
214
|
+
*
|
|
215
|
+
* Sharing is not enough: the dereferencer and every renderer walk each path
|
|
216
|
+
* through the graph, so `l2: {l: *l1, r: *l1}` doubles per level (a 724-byte
|
|
217
|
+
* spec took 6.8 s to dereference at depth 22, and a `summary` aliased the
|
|
218
|
+
* same way made a 147 MB page). So the copy also counts the expanded size
|
|
219
|
+
* (memoized per node, so counting is linear too) and refuses a document past
|
|
220
|
+
* the bound, as the `yaml` parser does with its alias limit. js-yaml, which
|
|
221
|
+
* dogsbay-ssg parses with, has no such limit. JSON never has either problem.
|
|
222
|
+
*/
|
|
223
|
+
function acyclic(root, label) {
|
|
224
|
+
const done = new Map();
|
|
225
|
+
const open = new Set();
|
|
226
|
+
const add = (a, b) => Math.min(SPEC_MAX_EXPANDED_CHARS + 1, a + b);
|
|
227
|
+
const copy = (v, depth) => {
|
|
228
|
+
if (typeof v === "string")
|
|
229
|
+
return { out: v, size: v.length + 2 };
|
|
230
|
+
if (!v || typeof v !== "object" || v instanceof Date)
|
|
231
|
+
return { out: v, size: 8 };
|
|
232
|
+
const finished = done.get(v);
|
|
233
|
+
if (finished)
|
|
234
|
+
return finished;
|
|
235
|
+
if (open.has(v))
|
|
236
|
+
return { out: "(circular)", size: 12 };
|
|
237
|
+
if (depth > 1_000)
|
|
238
|
+
throw new Error(`${label} is nested more than 1,000 levels deep`);
|
|
239
|
+
open.add(v);
|
|
240
|
+
let size = 2;
|
|
241
|
+
let out;
|
|
242
|
+
if (Array.isArray(v)) {
|
|
243
|
+
out = v.map((e) => {
|
|
244
|
+
const c = copy(e, depth + 1);
|
|
245
|
+
size = add(size, c.size + 1);
|
|
246
|
+
return c.out;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
out = {};
|
|
251
|
+
for (const [k, e] of Object.entries(v)) {
|
|
252
|
+
const c = copy(e, depth + 1);
|
|
253
|
+
size = add(size, c.size + k.length + 4);
|
|
254
|
+
out[k] = c.out;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
open.delete(v);
|
|
258
|
+
const r = { out, size };
|
|
259
|
+
done.set(v, r);
|
|
260
|
+
return r;
|
|
261
|
+
};
|
|
262
|
+
const r = copy(root, 0);
|
|
263
|
+
if (r.size > SPEC_MAX_EXPANDED_CHARS) {
|
|
264
|
+
throw new Error(`${label} expands to more than ${SPEC_MAX_EXPANDED_CHARS.toLocaleString("en")} characters through repeated YAML aliases`);
|
|
265
|
+
}
|
|
266
|
+
return r.out;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Prepared specs by path, each parsed and dereferenced once however many
|
|
270
|
+
* pages embed it. `read` returns the file's text; failures are cached too,
|
|
271
|
+
* so a broken spec is reported per page without being parsed again.
|
|
272
|
+
*/
|
|
273
|
+
export function createSpecCache(read, parseYaml) {
|
|
274
|
+
const specs = new Map();
|
|
275
|
+
return {
|
|
276
|
+
get(path) {
|
|
277
|
+
let p = specs.get(path);
|
|
278
|
+
if (!p) {
|
|
279
|
+
p = Promise.resolve()
|
|
280
|
+
.then(() => read(path))
|
|
281
|
+
.then((text) => prepareSpec(parseSpecText(text, parseYaml), path));
|
|
282
|
+
specs.set(path, p);
|
|
283
|
+
}
|
|
284
|
+
return p;
|
|
285
|
+
},
|
|
286
|
+
get size() {
|
|
287
|
+
return specs.size;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** A spec field read as text: a YAML alias can put any value where a string belongs. */
|
|
292
|
+
const txt = (v) => (typeof v === "string" ? v : undefined);
|
|
293
|
+
// ─── Values copied from the spec ─────────────────────────────────────
|
|
294
|
+
/**
|
|
295
|
+
* Bounds on the spec values an operation's props carry verbatim: examples,
|
|
296
|
+
* defaults and enums. A YAML anchor repeated through aliases is ONE object
|
|
297
|
+
* in the parsed spec and a full copy in every place a renderer serializes
|
|
298
|
+
* it: a 300-byte spec grew to a 33 MB page and 720 MB of memory in
|
|
299
|
+
* dogsbay-ssg, and an example that refers to itself crashed the build
|
|
300
|
+
* (JSON.stringify on a cycle). Values are copied here, so props are plain,
|
|
301
|
+
* acyclic and bounded, and every renderer is protected.
|
|
302
|
+
*/
|
|
303
|
+
const VALUE_MAX_CHARS = 4_000; // one default, enum entry or property example
|
|
304
|
+
const EXAMPLE_MAX_CHARS = 50_000; // a request or response example
|
|
305
|
+
const ENUM_MAX_VALUES = 200;
|
|
306
|
+
const OPERATION_MAX_CHARS = 500_000; // everything copied for one operation
|
|
307
|
+
const VALUE_MAX_DEPTH = 32;
|
|
308
|
+
/** What a value over its bound is shown as. */
|
|
309
|
+
export const VALUE_TOO_LARGE = "(not shown: too large)";
|
|
310
|
+
/** The budget of the operation being collected (collectOperations is synchronous). */
|
|
311
|
+
let operationBudget = OPERATION_MAX_CHARS;
|
|
312
|
+
class TooLarge extends Error {
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* A plain JSON copy of a spec value within `max` serialized characters (and
|
|
316
|
+
* the operation's remaining budget), or VALUE_TOO_LARGE. A reference back
|
|
317
|
+
* to an enclosing value becomes "(circular)". Counting stops at the bound,
|
|
318
|
+
* so an exponentially aliased value costs no more than the bound to refuse.
|
|
319
|
+
*/
|
|
320
|
+
function copyValue(v, max = VALUE_MAX_CHARS) {
|
|
321
|
+
if (v === undefined)
|
|
322
|
+
return undefined;
|
|
323
|
+
const limit = Math.min(max, operationBudget);
|
|
324
|
+
let left = limit;
|
|
325
|
+
const ancestors = new Set();
|
|
326
|
+
const spend = (n) => {
|
|
327
|
+
if ((left -= n) < 0)
|
|
328
|
+
throw new TooLarge();
|
|
329
|
+
};
|
|
330
|
+
const copy = (x, depth) => {
|
|
331
|
+
if (x === null || x === undefined)
|
|
332
|
+
return spend(4), null;
|
|
333
|
+
if (typeof x === "string")
|
|
334
|
+
return spend(x.length + 2), x;
|
|
335
|
+
if (typeof x === "number" || typeof x === "boolean")
|
|
336
|
+
return spend(String(x).length), x;
|
|
337
|
+
if (x instanceof Date)
|
|
338
|
+
return spend(26), Number.isNaN(x.getTime()) ? String(x) : x.toISOString();
|
|
339
|
+
if (typeof x !== "object")
|
|
340
|
+
return spend(String(x).length + 2), String(x);
|
|
341
|
+
if (ancestors.has(x))
|
|
342
|
+
return spend(10), "(circular)";
|
|
343
|
+
if (depth >= VALUE_MAX_DEPTH)
|
|
344
|
+
throw new TooLarge();
|
|
345
|
+
ancestors.add(x);
|
|
346
|
+
try {
|
|
347
|
+
if (Array.isArray(x))
|
|
348
|
+
return spend(2), x.map((e) => (spend(1), copy(e, depth + 1)));
|
|
349
|
+
const out = {};
|
|
350
|
+
spend(2);
|
|
351
|
+
for (const [k, e] of Object.entries(x)) {
|
|
352
|
+
spend(k.length + 4);
|
|
353
|
+
out[k] = copy(e, depth + 1);
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
ancestors.delete(x);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
const out = copy(v, 0);
|
|
363
|
+
operationBudget -= limit - left;
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (!(err instanceof TooLarge))
|
|
368
|
+
throw err;
|
|
369
|
+
operationBudget -= Math.min(operationBudget, VALUE_TOO_LARGE.length);
|
|
370
|
+
return VALUE_TOO_LARGE;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** An enum's values, each copied with copyValue, at most ENUM_MAX_VALUES; a cut list ends in "…". */
|
|
374
|
+
function copyEnum(list) {
|
|
375
|
+
if (!Array.isArray(list))
|
|
376
|
+
return undefined;
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const v of list) {
|
|
379
|
+
if (out.length >= ENUM_MAX_VALUES || operationBudget <= 0)
|
|
380
|
+
return [...out, "…"];
|
|
381
|
+
out.push(copyValue(v));
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
/** Every operation in the spec, in document order, as `endpoint` props. */
|
|
386
|
+
function collectOperations(spec) {
|
|
387
|
+
const out = [];
|
|
388
|
+
const baseUrl = txt(spec.servers?.[0]?.url) ?? "";
|
|
389
|
+
for (const [path, pathItem] of Object.entries(spec.paths || {})) {
|
|
390
|
+
if (!pathItem)
|
|
391
|
+
continue;
|
|
392
|
+
for (const method of HTTP_METHODS) {
|
|
393
|
+
const op = buildOperation(path, pathItem, method, baseUrl);
|
|
394
|
+
if (op)
|
|
395
|
+
out.push(op);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
/** One operation as `endpoint` props, or undefined when the path item has none for `method`. */
|
|
401
|
+
function buildOperation(path, pathItem, method, baseUrl) {
|
|
402
|
+
const operation = pathItem?.[method];
|
|
403
|
+
if (!operation)
|
|
404
|
+
return undefined;
|
|
405
|
+
operationBudget = OPERATION_MAX_CHARS;
|
|
406
|
+
const opTags = Array.isArray(operation.tags) ? operation.tags.filter((t) => typeof t === "string") : [];
|
|
407
|
+
const props = {
|
|
408
|
+
method,
|
|
409
|
+
path,
|
|
410
|
+
baseUrl,
|
|
411
|
+
summary: txt(operation.summary) || undefined,
|
|
412
|
+
description: txt(operation.description) || undefined,
|
|
413
|
+
operationId: txt(operation.operationId) || undefined,
|
|
414
|
+
tags: opTags.length > 0 ? opTags : undefined,
|
|
415
|
+
deprecated: operation.deprecated === true ? true : undefined,
|
|
416
|
+
parameters: extractParameters(operation, pathItem),
|
|
417
|
+
...extractRequestBody(operation),
|
|
418
|
+
responses: extractResponses(operation),
|
|
419
|
+
codeSamples: resolveCodeSamples(operation, method, path, baseUrl),
|
|
420
|
+
};
|
|
421
|
+
return { method, path, operation, props };
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The operation a `:::openapi` directive selects from its props, or why it
|
|
425
|
+
* cannot: `path` and `method` come together or not at all.
|
|
426
|
+
*/
|
|
427
|
+
export function directiveOperation(props) {
|
|
428
|
+
const path = typeof props?.path === "string" ? props.path.trim() : "";
|
|
429
|
+
const method = typeof props?.method === "string" ? props.method.trim().toLowerCase() : "";
|
|
430
|
+
if (!path && !method)
|
|
431
|
+
return {};
|
|
432
|
+
if (!path || !method)
|
|
433
|
+
return { error: "`path` and `method` select an operation together; give both or neither" };
|
|
434
|
+
if (!HTTP_METHODS.includes(method))
|
|
435
|
+
return { error: `not an HTTP method: ${method}` };
|
|
436
|
+
return { operation: { path, method } };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A whole spec as the BODY of one page: what Swagger UI or Redoc show when a
|
|
440
|
+
* page embeds them. The info version and description, then a section per tag
|
|
441
|
+
* (declared order, then first use; untagged operations last, under "Other"),
|
|
442
|
+
* each holding its operations as `endpoint` nodes in document order.
|
|
443
|
+
*
|
|
444
|
+
* `spec` is a dereferenced spec (`prepareSpec`), and is not modified: the
|
|
445
|
+
* nodes are copies, so one parsed spec serves every page that embeds it.
|
|
446
|
+
*
|
|
447
|
+
* `specToPages` is the other shape — a page per operation — for a spec
|
|
448
|
+
* that IS the source. This one is for a spec a prose page points at.
|
|
449
|
+
*/
|
|
450
|
+
export function embedSpec(spec, options = {}) {
|
|
451
|
+
if (options.operation) {
|
|
452
|
+
const { path, method } = options.operation;
|
|
453
|
+
// Only this operation is built: building them all to keep one made a page per operation
|
|
454
|
+
// quadratic in the spec (400 pages over a 400-operation spec: 4 s, in dogsbay-ssg).
|
|
455
|
+
const m = method.toLowerCase();
|
|
456
|
+
const paths = spec.paths && typeof spec.paths === "object" ? spec.paths : {};
|
|
457
|
+
const op = HTTP_METHODS.includes(m) && Object.hasOwn(paths, path) ? buildOperation(path, paths[path], m, txt(spec.servers?.[0]?.url) ?? "") : undefined;
|
|
458
|
+
if (!op)
|
|
459
|
+
throw new Error(`the spec has no operation ${method.toUpperCase()} ${path}`);
|
|
460
|
+
return { tree: [{ type: "endpoint", props: structuredClone(op.props) }], headings: [], operations: 1 };
|
|
461
|
+
}
|
|
462
|
+
const level = Math.min(6, Math.max(2, options.level ?? 2));
|
|
463
|
+
const tree = [];
|
|
464
|
+
const headings = [];
|
|
465
|
+
const used = new Set(options.usedIds ?? []);
|
|
466
|
+
// A description's own headings go under the section that holds it, with
|
|
467
|
+
// ids, and into the outline (a `# Title` was a second H1).
|
|
468
|
+
const describe = (text, minLevel) => {
|
|
469
|
+
const d = descriptionHtml(text, minLevel, used);
|
|
470
|
+
headings.push(...d.headings);
|
|
471
|
+
tree.push({ type: "prose", html: d.html });
|
|
472
|
+
};
|
|
473
|
+
const info = spec.info || {};
|
|
474
|
+
if (txt(info.version) || typeof info.version === "number") {
|
|
475
|
+
tree.push({
|
|
476
|
+
type: "prose",
|
|
477
|
+
html: `<p class="text-sm text-muted-foreground">Version ${escapeHtml(String(info.version))}</p>`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (typeof info.description === "string" && info.description.trim()) {
|
|
481
|
+
describe(info.description, level);
|
|
482
|
+
}
|
|
483
|
+
const tagDescriptions = new Map();
|
|
484
|
+
for (const t of spec.tags || []) {
|
|
485
|
+
if (txt(t?.name))
|
|
486
|
+
tagDescriptions.set(t.name, txt(t.description) ?? "");
|
|
487
|
+
}
|
|
488
|
+
const groups = new Map();
|
|
489
|
+
for (const t of tagDescriptions.keys())
|
|
490
|
+
groups.set(t, []);
|
|
491
|
+
const untagged = [];
|
|
492
|
+
const operations = collectOperations(spec);
|
|
493
|
+
for (const { props } of operations) {
|
|
494
|
+
// A copy: enum, default and example values are the spec's own objects, and the spec is shared.
|
|
495
|
+
const node = { type: "endpoint", props: structuredClone(props) };
|
|
496
|
+
const tag = props.tags?.[0];
|
|
497
|
+
if (!tag) {
|
|
498
|
+
untagged.push(node);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (!groups.has(tag))
|
|
502
|
+
groups.set(tag, []);
|
|
503
|
+
groups.get(tag).push(node);
|
|
504
|
+
}
|
|
505
|
+
if (untagged.length)
|
|
506
|
+
groups.set("\u0000other", untagged);
|
|
507
|
+
for (const [tag, nodes] of groups) {
|
|
508
|
+
if (!nodes.length)
|
|
509
|
+
continue;
|
|
510
|
+
const text = tag === "\u0000other" ? "Other" : tag;
|
|
511
|
+
const id = uniqueId(slugifyTag(text) || "section", used);
|
|
512
|
+
tree.push({
|
|
513
|
+
type: "heading",
|
|
514
|
+
props: { level, id },
|
|
515
|
+
inline: [{ type: "text", text: text }],
|
|
516
|
+
});
|
|
517
|
+
headings.push({ depth: level, slug: id, text });
|
|
518
|
+
const description = tagDescriptions.get(tag);
|
|
519
|
+
if (description)
|
|
520
|
+
describe(description, level + 1);
|
|
521
|
+
tree.push(...nodes);
|
|
522
|
+
}
|
|
523
|
+
return { tree, headings, operations: operations.length };
|
|
524
|
+
}
|
|
525
|
+
function uniqueId(base, used) {
|
|
526
|
+
let id = base;
|
|
527
|
+
for (let n = 2; used.has(id); n++)
|
|
528
|
+
id = `${base}-${n}`;
|
|
529
|
+
used.add(id);
|
|
530
|
+
return id;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A description (CommonMark) → HTML with its headings shifted so the
|
|
534
|
+
* highest is `minLevel`, each given a unique id, and listed for the TOC.
|
|
535
|
+
* Raw HTML stays off (see `md`).
|
|
536
|
+
*/
|
|
537
|
+
function descriptionHtml(text, minLevel, used) {
|
|
538
|
+
const tokens = md.parse(text, {});
|
|
539
|
+
const levels = tokens.filter((t) => t.type === "heading_open").map((t) => Number(t.tag.slice(1)));
|
|
540
|
+
const headings = [];
|
|
541
|
+
if (levels.length) {
|
|
542
|
+
const shift = Math.max(0, minLevel - Math.min(...levels));
|
|
543
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
544
|
+
const t = tokens[i];
|
|
545
|
+
if (t.type !== "heading_open")
|
|
546
|
+
continue;
|
|
547
|
+
const depth = Math.min(6, Number(t.tag.slice(1)) + shift);
|
|
548
|
+
const inline = tokens[i + 1];
|
|
549
|
+
const label = (inline?.children ?? [])
|
|
550
|
+
.filter((c) => c.type === "text" || c.type === "code_inline")
|
|
551
|
+
.map((c) => c.content)
|
|
552
|
+
.join("")
|
|
553
|
+
.trim();
|
|
554
|
+
const id = uniqueId(slugifyTag(label) || "section", used);
|
|
555
|
+
t.tag = `h${depth}`;
|
|
556
|
+
t.attrSet("id", id);
|
|
557
|
+
if (tokens[i + 2]?.type === "heading_close")
|
|
558
|
+
tokens[i + 2].tag = `h${depth}`;
|
|
559
|
+
headings.push({ depth, slug: id, text: label });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { html: md.renderer.render(tokens, md.options, {}).trim(), headings };
|
|
563
|
+
}
|
|
564
|
+
function normalisePrefix(p) {
|
|
565
|
+
if (!p)
|
|
566
|
+
return "";
|
|
567
|
+
return p.replace(/\/+$/, "");
|
|
568
|
+
}
|
|
569
|
+
/** The one non-null type of a schema: OpenAPI 3.1 (and an upgraded 2.0
|
|
570
|
+
* spec) writes a nullable object as `type: [object, "null"]`. */
|
|
571
|
+
function primaryType(schema) {
|
|
572
|
+
if (!schema)
|
|
573
|
+
return undefined;
|
|
574
|
+
if (!Array.isArray(schema.type))
|
|
575
|
+
return schema.type;
|
|
576
|
+
const types = schema.type.filter((t) => t !== "null");
|
|
577
|
+
return types.length === 1 ? types[0] : undefined;
|
|
578
|
+
}
|
|
579
|
+
function schemaToType(schema, depth = 0) {
|
|
580
|
+
if (!schema)
|
|
581
|
+
return "any";
|
|
582
|
+
// An array of itself (a cycle once dereferenced) has no finite name.
|
|
583
|
+
if (depth > SCHEMA_MAX_DEPTH)
|
|
584
|
+
return "any";
|
|
585
|
+
// OpenAPI 3.1 (and a Swagger 2.0 spec upgraded to it) writes nullable as
|
|
586
|
+
// a type list: `type: [string, "null"]`.
|
|
587
|
+
if (Array.isArray(schema.type)) {
|
|
588
|
+
const types = schema.type.filter((t) => t !== "null");
|
|
589
|
+
if (types.length === 1)
|
|
590
|
+
return schemaToType({ ...schema, type: types[0] }, depth + 1);
|
|
591
|
+
return types.length ? types.join(" | ") : "null";
|
|
592
|
+
}
|
|
593
|
+
if (schema.type === "array") {
|
|
594
|
+
const itemType = schema.items ? schemaToType(schema.items, depth + 1) : "any";
|
|
595
|
+
return `${itemType}[]`;
|
|
596
|
+
}
|
|
597
|
+
return schema.type || "any";
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Bounds on walking one schema. A dereferenced spec is a GRAPH: a
|
|
601
|
+
* self-referencing schema is a cycle (the walk overflowed the stack), and
|
|
602
|
+
* eight properties that each reference the level below grow 8× per level
|
|
603
|
+
* (a 2.2 KB spec made 18 MB of tree at depth 6). Past a bound the walk stops
|
|
604
|
+
* and the property shows without children.
|
|
605
|
+
*/
|
|
606
|
+
const SCHEMA_MAX_DEPTH = 10;
|
|
607
|
+
const SCHEMA_MAX_NODES = 2000;
|
|
608
|
+
const newWalk = () => ({ nodes: SCHEMA_MAX_NODES, depth: 0, ancestors: new Set() });
|
|
609
|
+
/** Run `f` one level into `schema`, or return `empty` past a bound. */
|
|
610
|
+
function descend(walk, schema, empty, f) {
|
|
611
|
+
if (!schema || typeof schema !== "object")
|
|
612
|
+
return f();
|
|
613
|
+
if (walk.depth >= SCHEMA_MAX_DEPTH || walk.nodes <= 0 || walk.ancestors.has(schema))
|
|
614
|
+
return empty;
|
|
615
|
+
walk.depth++;
|
|
616
|
+
walk.ancestors.add(schema);
|
|
617
|
+
try {
|
|
618
|
+
return f();
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
walk.depth--;
|
|
622
|
+
walk.ancestors.delete(schema);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function extractSchemaProperties(schema, requiredFields, walk = newWalk()) {
|
|
626
|
+
if (!schema?.properties)
|
|
627
|
+
return [];
|
|
628
|
+
return descend(walk, schema, [], () => propertiesOf(schema, requiredFields, walk));
|
|
629
|
+
}
|
|
630
|
+
function propertiesOf(schema, requiredFields, walk) {
|
|
631
|
+
const required = requiredFields || schema.required || [];
|
|
632
|
+
const out = [];
|
|
633
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
634
|
+
if (walk.nodes-- <= 0)
|
|
635
|
+
break;
|
|
636
|
+
out.push(propertyOf(name, prop ?? {}, required, walk));
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function propertyOf(name, prop, required, walk) {
|
|
641
|
+
const result = {
|
|
642
|
+
name,
|
|
643
|
+
type: schemaToType(prop),
|
|
644
|
+
required: required.includes(name),
|
|
645
|
+
};
|
|
646
|
+
if (txt(prop.description))
|
|
647
|
+
result.description = prop.description;
|
|
648
|
+
if (prop.nullable)
|
|
649
|
+
result.nullable = true;
|
|
650
|
+
if (txt(prop.format))
|
|
651
|
+
result.format = prop.format;
|
|
652
|
+
if (prop.enum)
|
|
653
|
+
result.enum = copyEnum(prop.enum);
|
|
654
|
+
if (prop.default !== undefined)
|
|
655
|
+
result.default = copyValue(prop.default);
|
|
656
|
+
if (prop.example !== undefined)
|
|
657
|
+
result.example = copyValue(prop.example);
|
|
658
|
+
if (primaryType(prop) === "object" && prop.properties) {
|
|
659
|
+
result.properties = extractSchemaProperties(prop, undefined, walk);
|
|
660
|
+
}
|
|
661
|
+
if (primaryType(prop) === "array" && prop.items) {
|
|
662
|
+
if (primaryType(prop.items) === "object" && prop.items.properties) {
|
|
663
|
+
result.items = extractSchemaProperties(prop.items, undefined, walk);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
function extractParameters(operation, pathItem) {
|
|
669
|
+
const params = [];
|
|
670
|
+
const allParams = [
|
|
671
|
+
...(pathItem.parameters || []),
|
|
672
|
+
...(operation.parameters || []),
|
|
673
|
+
];
|
|
674
|
+
// Operation-level wins over path-level on (name, in) collision.
|
|
675
|
+
const seen = new Map();
|
|
676
|
+
for (const p of allParams) {
|
|
677
|
+
if (!txt(p?.name) || !txt(p?.in))
|
|
678
|
+
continue;
|
|
679
|
+
seen.set(`${p.name}:${p.in}`, p);
|
|
680
|
+
}
|
|
681
|
+
for (const p of seen.values()) {
|
|
682
|
+
const param = {
|
|
683
|
+
name: p.name,
|
|
684
|
+
in: p.in,
|
|
685
|
+
type: schemaToType(p.schema),
|
|
686
|
+
required: p.required || false,
|
|
687
|
+
};
|
|
688
|
+
if (txt(p.description))
|
|
689
|
+
param.description = p.description;
|
|
690
|
+
if (p.schema?.default !== undefined) {
|
|
691
|
+
const d = copyValue(p.schema.default);
|
|
692
|
+
param.default = typeof d === "string" ? d : JSON.stringify(d);
|
|
693
|
+
}
|
|
694
|
+
if (p.schema?.enum)
|
|
695
|
+
param.enum = copyEnum(p.schema.enum);
|
|
696
|
+
if (p.deprecated)
|
|
697
|
+
param.deprecated = true;
|
|
698
|
+
params.push(param);
|
|
699
|
+
}
|
|
700
|
+
return params;
|
|
701
|
+
}
|
|
702
|
+
function extractRequestBody(operation) {
|
|
703
|
+
const body = operation.requestBody;
|
|
704
|
+
if (!body?.content)
|
|
705
|
+
return {};
|
|
706
|
+
const mediaType = body.content["application/json"] ||
|
|
707
|
+
Object.values(body.content)[0];
|
|
708
|
+
if (!mediaType?.schema)
|
|
709
|
+
return {};
|
|
710
|
+
return {
|
|
711
|
+
requestBody: extractSchemaProperties(mediaType.schema),
|
|
712
|
+
requestBodyExample: copyValue(generateExampleFromSchema(mediaType.schema), EXAMPLE_MAX_CHARS),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function extractResponses(operation) {
|
|
716
|
+
if (!operation.responses)
|
|
717
|
+
return [];
|
|
718
|
+
return Object.entries(operation.responses)
|
|
719
|
+
.filter(([status]) => status !== "default")
|
|
720
|
+
.map(([status, resp]) => {
|
|
721
|
+
const result = {
|
|
722
|
+
status,
|
|
723
|
+
description: txt(resp.description),
|
|
724
|
+
};
|
|
725
|
+
if (resp.content) {
|
|
726
|
+
const contentTypeKey = resp.content["application/json"]
|
|
727
|
+
? "application/json"
|
|
728
|
+
: Object.keys(resp.content)[0];
|
|
729
|
+
const mediaType = resp.content[contentTypeKey];
|
|
730
|
+
if (contentTypeKey)
|
|
731
|
+
result.contentType = contentTypeKey;
|
|
732
|
+
if (mediaType?.schema) {
|
|
733
|
+
const rawSchema = mediaType.schema;
|
|
734
|
+
const schema = primaryType(rawSchema) === "array" && rawSchema.items
|
|
735
|
+
? rawSchema.items
|
|
736
|
+
: rawSchema;
|
|
737
|
+
result.schema = extractSchemaProperties(schema);
|
|
738
|
+
result.example = copyValue(generateExampleFromSchema(rawSchema), EXAMPLE_MAX_CHARS);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return result;
|
|
742
|
+
})
|
|
743
|
+
.sort((a, b) => a.status.localeCompare(b.status));
|
|
744
|
+
}
|
|
745
|
+
function generateExampleFromSchema(schema, walk = newWalk()) {
|
|
746
|
+
if (!schema)
|
|
747
|
+
return undefined;
|
|
748
|
+
if (walk.nodes-- <= 0)
|
|
749
|
+
return null;
|
|
750
|
+
return descend(walk, schema, null, () => exampleOf(schema, walk));
|
|
751
|
+
}
|
|
752
|
+
function exampleOf(schema, walk) {
|
|
753
|
+
if (schema.example !== undefined)
|
|
754
|
+
return schema.example;
|
|
755
|
+
switch (primaryType(schema)) {
|
|
756
|
+
case "object": {
|
|
757
|
+
if (!schema.properties)
|
|
758
|
+
return {};
|
|
759
|
+
const obj = {};
|
|
760
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
761
|
+
obj[name] = generateExampleFromSchema(prop, walk);
|
|
762
|
+
}
|
|
763
|
+
return obj;
|
|
764
|
+
}
|
|
765
|
+
case "array":
|
|
766
|
+
return schema.items ? [generateExampleFromSchema(schema.items, walk)] : [];
|
|
767
|
+
case "string": {
|
|
768
|
+
if (schema.enum)
|
|
769
|
+
return schema.enum[0];
|
|
770
|
+
if (schema.format === "date-time")
|
|
771
|
+
return "2024-01-01T00:00:00Z";
|
|
772
|
+
if (schema.format === "email")
|
|
773
|
+
return "user@example.com";
|
|
774
|
+
if (schema.format === "uri" || schema.format === "url") {
|
|
775
|
+
return "https://example.com";
|
|
776
|
+
}
|
|
777
|
+
if (schema.format === "uuid") {
|
|
778
|
+
return "550e8400-e29b-41d4-a716-446655440000";
|
|
779
|
+
}
|
|
780
|
+
if (schema.default !== undefined)
|
|
781
|
+
return schema.default;
|
|
782
|
+
return "string";
|
|
783
|
+
}
|
|
784
|
+
case "integer":
|
|
785
|
+
return schema.example ?? schema.default ?? 0;
|
|
786
|
+
case "number":
|
|
787
|
+
return schema.example ?? schema.default ?? 0.0;
|
|
788
|
+
case "boolean":
|
|
789
|
+
return schema.example ?? schema.default ?? true;
|
|
790
|
+
default:
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function buildTagPage(tagName, tagSlug, description, ops) {
|
|
795
|
+
const tree = [
|
|
796
|
+
{
|
|
797
|
+
type: "heading",
|
|
798
|
+
props: { level: 1, id: tagSlug },
|
|
799
|
+
inline: [{ type: "text", text: tagName }],
|
|
800
|
+
},
|
|
801
|
+
];
|
|
802
|
+
const used = new Set([tagSlug]);
|
|
803
|
+
const described = description ? descriptionHtml(description, 2, used) : null;
|
|
804
|
+
if (described)
|
|
805
|
+
tree.push({ type: "prose", html: described.html });
|
|
806
|
+
// Link nodes with site-root hrefs, as an importer writes them, so the build
|
|
807
|
+
// adds the base path and the source's /<name>/ prefix like any other link.
|
|
808
|
+
// These were relative `<a href="pets/list-pets">` in raw HTML: on the tag
|
|
809
|
+
// page, served at /pets/, the browser read them as /pets/pets/list-pets.
|
|
810
|
+
const items = ops.map((op) => ({
|
|
811
|
+
type: "list-item",
|
|
812
|
+
inline: [
|
|
813
|
+
{
|
|
814
|
+
type: "link",
|
|
815
|
+
href: `/${op.slug}`,
|
|
816
|
+
children: [
|
|
817
|
+
{ type: "code", text: op.method.toUpperCase() },
|
|
818
|
+
{ type: "text", text: ` ${op.path}` },
|
|
819
|
+
],
|
|
820
|
+
},
|
|
821
|
+
...(op.deprecated ? [{ type: "text", text: " (deprecated)" }] : []),
|
|
822
|
+
],
|
|
823
|
+
}));
|
|
824
|
+
tree.push({ type: "unordered-list", children: items });
|
|
825
|
+
return {
|
|
826
|
+
slug: tagSlug,
|
|
827
|
+
title: tagName,
|
|
828
|
+
tree,
|
|
829
|
+
headings: [{ depth: 1, slug: tagSlug, text: tagName }, ...(described?.headings ?? [])],
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function buildInfoPage(title, description, version) {
|
|
833
|
+
const tree = [
|
|
834
|
+
{
|
|
835
|
+
type: "heading",
|
|
836
|
+
props: { level: 1, id: "api-reference" },
|
|
837
|
+
inline: [{ type: "text", text: title }],
|
|
838
|
+
},
|
|
839
|
+
];
|
|
840
|
+
if (version) {
|
|
841
|
+
tree.push({
|
|
842
|
+
type: "prose",
|
|
843
|
+
html: `<p class="text-sm text-muted-foreground">Version ${escapeHtml(version)}</p>`,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const described = description ? descriptionHtml(description, 2, new Set(["api-reference"])) : null;
|
|
847
|
+
if (described)
|
|
848
|
+
tree.push({ type: "prose", html: described.html });
|
|
849
|
+
return {
|
|
850
|
+
slug: "index",
|
|
851
|
+
title,
|
|
852
|
+
tree,
|
|
853
|
+
headings: [{ depth: 1, slug: "api-reference", text: title }, ...(described?.headings ?? [])],
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function buildNav(spec, pages, tagToOps, untagged, hrefPrefix, tagSlugs) {
|
|
857
|
+
const nav = [];
|
|
858
|
+
const root = {
|
|
859
|
+
label: txt(spec.info?.title) || "API Reference",
|
|
860
|
+
href: hrefHref(hrefPrefix, "index"),
|
|
861
|
+
};
|
|
862
|
+
nav.push(root);
|
|
863
|
+
// Use spec.tags[] order when present so authors control nav order.
|
|
864
|
+
const declaredTagOrder = Array.isArray(spec.tags)
|
|
865
|
+
? spec.tags.map((t) => txt(t?.name)).filter(Boolean)
|
|
866
|
+
: [];
|
|
867
|
+
const seenTags = new Set();
|
|
868
|
+
const tagOrder = [];
|
|
869
|
+
for (const t of declaredTagOrder) {
|
|
870
|
+
if (tagToOps.has(t)) {
|
|
871
|
+
tagOrder.push(t);
|
|
872
|
+
seenTags.add(t);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
for (const t of tagToOps.keys()) {
|
|
876
|
+
if (!seenTags.has(t))
|
|
877
|
+
tagOrder.push(t);
|
|
878
|
+
}
|
|
879
|
+
for (const tagName of tagOrder) {
|
|
880
|
+
const ops = tagToOps.get(tagName) || [];
|
|
881
|
+
const tagSlug = tagSlugs.get(tagName);
|
|
882
|
+
nav.push({
|
|
883
|
+
label: tagName,
|
|
884
|
+
href: hrefHref(hrefPrefix, tagSlug),
|
|
885
|
+
children: ops
|
|
886
|
+
.slice()
|
|
887
|
+
.sort(byMethodThenPath)
|
|
888
|
+
.map((op) => ({
|
|
889
|
+
label: op.title,
|
|
890
|
+
href: hrefHref(hrefPrefix, op.slug),
|
|
891
|
+
})),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (untagged.length > 0) {
|
|
895
|
+
nav.push({
|
|
896
|
+
label: "Other",
|
|
897
|
+
children: untagged
|
|
898
|
+
.slice()
|
|
899
|
+
.sort(byMethodThenPath)
|
|
900
|
+
.map((op) => ({
|
|
901
|
+
label: op.title,
|
|
902
|
+
href: hrefHref(hrefPrefix, op.slug),
|
|
903
|
+
})),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
return nav;
|
|
907
|
+
}
|
|
908
|
+
function byMethodThenPath(a, b) {
|
|
909
|
+
const m = a.method.localeCompare(b.method);
|
|
910
|
+
if (m !== 0)
|
|
911
|
+
return m;
|
|
912
|
+
return a.path.localeCompare(b.path);
|
|
913
|
+
}
|
|
914
|
+
function hrefHref(prefix, slug) {
|
|
915
|
+
const p = prefix ? `${prefix}/` : "/";
|
|
916
|
+
return slug === "index" ? p : `${p}${slug}`;
|
|
917
|
+
}
|
|
918
|
+
function escapeHtml(s) {
|
|
919
|
+
return s
|
|
920
|
+
.replace(/&/g, "&")
|
|
921
|
+
.replace(/</g, "<")
|
|
922
|
+
.replace(/>/g, ">");
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* A spec description (CommonMark, per OpenAPI) → HTML, raw HTML off: a `<`
|
|
926
|
+
* in the spec is escaped, and markdown-it refuses `javascript:` links.
|
|
927
|
+
* Plain-text paragraphs showed a spec's `## Authentication` literally.
|
|
928
|
+
*/
|
|
929
|
+
const md = new MarkdownIt({ html: false, linkify: false });
|
|
930
|
+
//# sourceMappingURL=core.js.map
|