@dogsbay/format-openapi 0.2.0-beta.14 → 0.2.0-beta.141

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/parse.js CHANGED
@@ -16,66 +16,111 @@
16
16
  * per-operation pages so the namespace renders as a self-contained
17
17
  * mini-site.
18
18
  */
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
20
  import { readFile } from "node:fs/promises";
21
21
  import { resolve, join, isAbsolute } from "node:path";
22
- import { dereference } from "@scalar/openapi-parser";
23
22
  import { parse as parseYaml } from "yaml";
24
- import { operationToSlug, slugifyTag } from "./slug.js";
25
- import { resolveCodeSamples } from "./code-samples.js";
26
- const HTTP_METHODS = [
27
- "get",
28
- "post",
29
- "put",
30
- "patch",
31
- "delete",
32
- "head",
33
- "options",
34
- ];
23
+ import { embedSpec, parseSpecText, prepareSpec, specToPages, } from "./core.js";
24
+ /** Extensions a spec file can plausibly have. See the note in detectSpecPath. */
25
+ const SPEC_FILE_EXT = /\.(ya?ml|json)$/i;
35
26
  /** Auto-detect spec location given the configured source path. */
36
27
  export function detectSpecPath(sourcePath) {
37
28
  if (sourcePath.startsWith("http://") || sourcePath.startsWith("https://")) {
38
29
  return sourcePath;
39
30
  }
40
31
  if (existsSync(sourcePath)) {
32
+ // Was `readFileSync(sourcePath)` — reading an entire (possibly multi-MB)
33
+ // spec into a Buffer purely to answer "is this a file", relying on EISDIR
34
+ // to signal a directory. `statSync` answers it directly.
41
35
  const stat = (() => {
42
36
  try {
43
- return readFileSync(sourcePath);
37
+ return statSync(sourcePath);
44
38
  }
45
39
  catch {
46
40
  return null;
47
41
  }
48
42
  })();
49
43
  // If it's a file, return as-is. If it's a directory, look inside.
44
+ //
45
+ // The extension check is load-bearing for AUTO-DETECTION: `detectSpecPath`
46
+ // used to return any existing file, and `detectOpenApiSource` then only
47
+ // scanned its first 2 KB for `openapi:` / `swagger:`. No earlier plugin in
48
+ // the registry claims a FILE path, so openapi-last did not protect
49
+ // anything — a Mintlify page carrying `openapi: "GET /v1/links"` in its
50
+ // frontmatter was detected as an OpenAPI spec and routed into this parser.
50
51
  try {
51
52
  const isFile = existsSync(sourcePath) && stat !== null;
52
- if (isFile && !isDirectory(sourcePath))
53
- return sourcePath;
53
+ if (isFile && !isDirectory(sourcePath)) {
54
+ return SPEC_FILE_EXT.test(sourcePath) ? sourcePath : null;
55
+ }
54
56
  }
55
57
  catch {
56
58
  // fall through to directory probe
57
59
  }
58
60
  if (isDirectory(sourcePath)) {
61
+ // `swagger.*` is here because the content check below explicitly accepts
62
+ // a `swagger:` declaration — without these names, the one filename that
63
+ // check was written for (Swagger 2.0 ships as `swagger.json` by
64
+ // convention) could never be found.
59
65
  const candidates = [
60
66
  "openapi.yaml",
61
67
  "openapi.yml",
62
68
  "openapi.json",
69
+ "swagger.yaml",
70
+ "swagger.yml",
71
+ "swagger.json",
63
72
  "spec.yaml",
64
73
  "spec.yml",
65
74
  "spec.json",
66
- ];
67
- for (const name of candidates) {
68
- const p = join(sourcePath, name);
69
- if (existsSync(p))
70
- return p;
71
- }
75
+ ].map((name) => join(sourcePath, name)).filter((p) => existsSync(p));
76
+ // Prefer the first candidate that actually LOOKS like a spec, rather
77
+ // than the first that merely exists. `openapi.yaml` is also a common
78
+ // name for codegen/CI config; when such a decoy sat next to a genuine
79
+ // `spec.yaml`, returning the decoy made the whole directory read as
80
+ // "not an OpenAPI source" and the real spec was never seen.
81
+ const declared = candidates.find((p) => declaresSpec(p));
82
+ if (declared)
83
+ return declared;
84
+ // Nothing sniffs as a spec: still return the first candidate so
85
+ // `parseOpenApiSpec` fails on the FILE (a parse error naming the path)
86
+ // rather than with a misleading "spec not found". Detection is
87
+ // unaffected — `detectOpenApiSource` re-applies the same check.
88
+ return candidates[0] ?? null;
72
89
  }
73
90
  }
74
91
  return null;
75
92
  }
93
+ /**
94
+ * Does this file declare `openapi:` / `swagger:` in its first 2 KB?
95
+ *
96
+ * The leading `[{,]?` matters: a MINIFIED JSON spec puts everything on one
97
+ * line (`{"openapi":"3.0.0",…`), so a purely line-anchored pattern never
98
+ * matched it and a perfectly valid spec was undetectable. Swagger 2.0 ships
99
+ * minified as `swagger.json` more often than not.
100
+ */
101
+ function declaresSpec(path) {
102
+ try {
103
+ const head = readFileSync(path, "utf-8").slice(0, 2048);
104
+ return (/(?:^|[{,])\s*"?openapi"?\s*[:=]/m.test(head) ||
105
+ /(?:^|[{,])\s*"?swagger"?\s*[:=]/m.test(head));
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ /**
112
+ * The package is `"type": "module"`, so a CJS `require` of node:fs here threw
113
+ * `require is not defined` on EVERY call — the catch swallowed it and this
114
+ * returned false unconditionally. Effect: `detectSpecPath` never took its
115
+ * directory branch, so a spec-only DIRECTORY was undetectable and only a
116
+ * direct path to the spec file worked.
117
+ *
118
+ * Vitest cannot see this: its SSR transform supplies a `require` shim, so the
119
+ * unit tests passed while the shipped ESM build was broken. Caught by running
120
+ * the actual CLI against a directory. Use the static import instead.
121
+ */
76
122
  function isDirectory(p) {
77
123
  try {
78
- const { statSync } = require("node:fs");
79
124
  return statSync(p).isDirectory();
80
125
  }
81
126
  catch {
@@ -95,14 +140,7 @@ export function detectOpenApiSource(path) {
95
140
  return true;
96
141
  // Sanity-check the file content briefly to avoid false-positives on
97
142
  // arbitrary YAML/JSON sitting in a sibling directory.
98
- try {
99
- const head = readFileSync(detected, "utf-8").slice(0, 2048);
100
- return /^\s*"?openapi"?\s*[:=]/m.test(head) ||
101
- /^\s*"?swagger"?\s*[:=]/m.test(head);
102
- }
103
- catch {
104
- return false;
105
- }
143
+ return declaresSpec(detected);
106
144
  }
107
145
  export async function parseOpenApiSpec(sourcePath, options = {}) {
108
146
  const specPath = detectSpecPath(sourcePath);
@@ -110,404 +148,29 @@ export async function parseOpenApiSpec(sourcePath, options = {}) {
110
148
  throw new Error(`OpenAPI spec not found at ${sourcePath}. Looked for openapi.{yaml,yml,json} ` +
111
149
  `and spec.{yaml,yml,json} (or expected a direct file path / URL).`);
112
150
  }
113
- const raw = await readSpecText(specPath);
114
- let parsed;
115
- try {
116
- parsed = JSON.parse(raw);
117
- }
118
- catch {
119
- parsed = parseYaml(raw);
120
- }
121
- const { schema: spec } = await dereference(parsed);
122
- if (!spec) {
123
- throw new Error(`Failed to dereference OpenAPI spec at ${specPath}`);
124
- }
125
- const baseUrl = spec.servers?.[0]?.url || "";
126
- const hrefPrefix = normalisePrefix(options.hrefPrefix);
127
- const pages = [];
128
- // Collect tag descriptions (from spec.tags[]) for tag overview pages.
129
- const tagDescriptions = new Map();
130
- for (const t of spec.tags || []) {
131
- if (t?.name)
132
- tagDescriptions.set(t.name, t.description || "");
133
- }
134
- // Walk operations.
135
- const paths = spec.paths || {};
136
- const tagToOps = new Map();
137
- const untagged = [];
138
- for (const [path, pathItem] of Object.entries(paths)) {
139
- if (!pathItem)
140
- continue;
141
- for (const method of HTTP_METHODS) {
142
- const operation = pathItem[method];
143
- if (!operation)
144
- continue;
145
- const opTags = Array.isArray(operation.tags)
146
- ? operation.tags
147
- : [];
148
- const primaryTag = opTags[0];
149
- const tagSlug = primaryTag ? slugifyTag(primaryTag) : "";
150
- const opSlug = operationToSlug(method, path, operation.operationId);
151
- const slug = tagSlug ? `${tagSlug}/${opSlug}` : opSlug;
152
- const props = {
153
- method,
154
- path,
155
- baseUrl,
156
- summary: operation.summary || undefined,
157
- description: operation.description || undefined,
158
- operationId: operation.operationId || undefined,
159
- tags: opTags.length > 0 ? opTags : undefined,
160
- deprecated: operation.deprecated === true ? true : undefined,
161
- parameters: extractParameters(operation, pathItem),
162
- ...extractRequestBody(operation),
163
- responses: extractResponses(operation),
164
- codeSamples: resolveCodeSamples(operation, method, path, baseUrl),
165
- };
166
- const title = operation.summary || `${method.toUpperCase()} ${path}`;
167
- const tree = [
168
- {
169
- type: "endpoint",
170
- props: props,
171
- },
172
- ];
173
- const headings = [{ depth: 1, slug: opSlug, text: title }];
174
- pages.push({
175
- slug,
176
- title,
177
- tree,
178
- headings,
179
- });
180
- const summary = {
181
- slug,
182
- title,
183
- method,
184
- path,
185
- deprecated: operation.deprecated === true,
186
- };
187
- if (primaryTag) {
188
- const arr = tagToOps.get(primaryTag) || [];
189
- arr.push(summary);
190
- tagToOps.set(primaryTag, arr);
191
- }
192
- else {
193
- untagged.push(summary);
194
- }
195
- }
196
- }
197
- // Tag overview pages.
198
- for (const [tagName, ops] of tagToOps) {
199
- const tagSlug = slugifyTag(tagName);
200
- const description = tagDescriptions.get(tagName) || "";
201
- pages.push(buildTagPage(tagName, tagSlug, description, ops));
202
- }
203
- // Root info page.
204
- const info = spec.info || {};
205
- pages.push(buildInfoPage(info.title || "API Reference", info.description || "", info.version || ""));
206
- // Build nav: tag groups in declaration order, with operations as
207
- // children. Untagged operations land under a synthetic "Other" group
208
- // so they're discoverable but visually separated.
209
- const nav = buildNav(spec, pages, tagToOps, untagged, hrefPrefix);
210
- return { pages, nav };
151
+ return specToPages(await loadSpec(specPath), options);
152
+ }
153
+ /** Read a spec file (or URL) and prepare it (core.ts `prepareSpec`). */
154
+ export async function loadSpec(specPath) {
155
+ return prepareSpec(parseSpecText(await readSpecText(specPath), parseYaml), specPath);
156
+ }
157
+ /**
158
+ * A whole spec as the BODY of one page (core.ts `embedSpec`), read from a
159
+ * file. Each call parses the spec; a build that embeds one spec on several
160
+ * pages uses `createSpecCache` and `embedSpec` instead.
161
+ */
162
+ export async function openApiEmbed(specPath, options = {}) {
163
+ return embedSpec(await loadSpec(specPath), options);
211
164
  }
212
165
  async function readSpecText(specPath) {
213
166
  if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
214
- const r = await fetch(specPath);
167
+ // Bound the fetch — a spec is loaded during `convert` / `site build`, so a
168
+ // slow/hung host must not stall the whole import indefinitely.
169
+ const r = await fetch(specPath, { signal: AbortSignal.timeout(30_000) });
170
+ if (!r.ok)
171
+ throw new Error(`HTTP ${r.status} fetching spec ${specPath}`);
215
172
  return await r.text();
216
173
  }
217
174
  const abs = isAbsolute(specPath) ? specPath : resolve(specPath);
218
175
  return await readFile(abs, "utf-8");
219
176
  }
220
- function normalisePrefix(p) {
221
- if (!p)
222
- return "";
223
- return p.replace(/\/+$/, "");
224
- }
225
- function schemaToType(schema) {
226
- if (!schema)
227
- return "any";
228
- if (schema.type === "array") {
229
- const itemType = schema.items ? schemaToType(schema.items) : "any";
230
- return `${itemType}[]`;
231
- }
232
- return schema.type || "any";
233
- }
234
- function extractSchemaProperties(schema, requiredFields) {
235
- if (!schema?.properties)
236
- return [];
237
- const required = requiredFields || schema.required || [];
238
- return Object.entries(schema.properties).map(([name, prop]) => {
239
- const result = {
240
- name,
241
- type: schemaToType(prop),
242
- required: required.includes(name),
243
- };
244
- if (prop.description)
245
- result.description = prop.description;
246
- if (prop.nullable)
247
- result.nullable = true;
248
- if (prop.format)
249
- result.format = prop.format;
250
- if (prop.enum)
251
- result.enum = prop.enum;
252
- if (prop.default !== undefined)
253
- result.default = prop.default;
254
- if (prop.example !== undefined)
255
- result.example = prop.example;
256
- if (prop.type === "object" && prop.properties) {
257
- result.properties = extractSchemaProperties(prop);
258
- }
259
- if (prop.type === "array" && prop.items) {
260
- if (prop.items.type === "object" && prop.items.properties) {
261
- result.items = extractSchemaProperties(prop.items);
262
- }
263
- }
264
- return result;
265
- });
266
- }
267
- function extractParameters(operation, pathItem) {
268
- const params = [];
269
- const allParams = [
270
- ...(pathItem.parameters || []),
271
- ...(operation.parameters || []),
272
- ];
273
- // Operation-level wins over path-level on (name, in) collision.
274
- const seen = new Map();
275
- for (const p of allParams) {
276
- if (!p?.name || !p?.in)
277
- continue;
278
- seen.set(`${p.name}:${p.in}`, p);
279
- }
280
- for (const p of seen.values()) {
281
- const param = {
282
- name: p.name,
283
- in: p.in,
284
- type: schemaToType(p.schema),
285
- required: p.required || false,
286
- };
287
- if (p.description)
288
- param.description = p.description;
289
- if (p.schema?.default !== undefined) {
290
- param.default = String(p.schema.default);
291
- }
292
- if (p.schema?.enum)
293
- param.enum = p.schema.enum;
294
- if (p.deprecated)
295
- param.deprecated = true;
296
- params.push(param);
297
- }
298
- return params;
299
- }
300
- function extractRequestBody(operation) {
301
- const body = operation.requestBody;
302
- if (!body?.content)
303
- return {};
304
- const mediaType = body.content["application/json"] ||
305
- Object.values(body.content)[0];
306
- if (!mediaType?.schema)
307
- return {};
308
- return {
309
- requestBody: extractSchemaProperties(mediaType.schema),
310
- requestBodyExample: generateExampleFromSchema(mediaType.schema),
311
- };
312
- }
313
- function extractResponses(operation) {
314
- if (!operation.responses)
315
- return [];
316
- return Object.entries(operation.responses)
317
- .filter(([status]) => status !== "default")
318
- .map(([status, resp]) => {
319
- const result = {
320
- status,
321
- description: resp.description,
322
- };
323
- if (resp.content) {
324
- const contentTypeKey = resp.content["application/json"]
325
- ? "application/json"
326
- : Object.keys(resp.content)[0];
327
- const mediaType = resp.content[contentTypeKey];
328
- if (contentTypeKey)
329
- result.contentType = contentTypeKey;
330
- if (mediaType?.schema) {
331
- const rawSchema = mediaType.schema;
332
- const schema = rawSchema.type === "array" && rawSchema.items
333
- ? rawSchema.items
334
- : rawSchema;
335
- result.schema = extractSchemaProperties(schema);
336
- result.example = generateExampleFromSchema(rawSchema);
337
- }
338
- }
339
- return result;
340
- })
341
- .sort((a, b) => a.status.localeCompare(b.status));
342
- }
343
- function generateExampleFromSchema(schema) {
344
- if (!schema)
345
- return undefined;
346
- if (schema.example !== undefined)
347
- return schema.example;
348
- switch (schema.type) {
349
- case "object": {
350
- if (!schema.properties)
351
- return {};
352
- const obj = {};
353
- for (const [name, prop] of Object.entries(schema.properties)) {
354
- obj[name] = generateExampleFromSchema(prop);
355
- }
356
- return obj;
357
- }
358
- case "array":
359
- return schema.items ? [generateExampleFromSchema(schema.items)] : [];
360
- case "string": {
361
- if (schema.enum)
362
- return schema.enum[0];
363
- if (schema.format === "date-time")
364
- return "2024-01-01T00:00:00Z";
365
- if (schema.format === "email")
366
- return "user@example.com";
367
- if (schema.format === "uri" || schema.format === "url") {
368
- return "https://example.com";
369
- }
370
- if (schema.format === "uuid") {
371
- return "550e8400-e29b-41d4-a716-446655440000";
372
- }
373
- if (schema.default !== undefined)
374
- return schema.default;
375
- return "string";
376
- }
377
- case "integer":
378
- return schema.example ?? schema.default ?? 0;
379
- case "number":
380
- return schema.example ?? schema.default ?? 0.0;
381
- case "boolean":
382
- return schema.example ?? schema.default ?? true;
383
- default:
384
- return null;
385
- }
386
- }
387
- function buildTagPage(tagName, tagSlug, description, ops) {
388
- const tree = [
389
- {
390
- type: "heading",
391
- props: { level: 1, id: tagSlug },
392
- children: [{ type: "prose", html: escapeHtml(tagName) }],
393
- },
394
- ];
395
- if (description) {
396
- tree.push({ type: "prose", html: paragraphHtml(description) });
397
- }
398
- const items = ops.map((op) => ({
399
- type: "list-item",
400
- children: [
401
- {
402
- type: "prose",
403
- html: `<a href="${escapeAttr(op.slug)}"><code>${op.method.toUpperCase()}</code> ${escapeHtml(op.path)}</a>${op.deprecated ? ' <span class="text-muted-foreground">(deprecated)</span>' : ""}`,
404
- },
405
- ],
406
- }));
407
- tree.push({ type: "unordered-list", children: items });
408
- return {
409
- slug: tagSlug,
410
- title: tagName,
411
- tree,
412
- headings: [{ depth: 1, slug: tagSlug, text: tagName }],
413
- };
414
- }
415
- function buildInfoPage(title, description, version) {
416
- const tree = [
417
- {
418
- type: "heading",
419
- props: { level: 1, id: "api-reference" },
420
- children: [{ type: "prose", html: escapeHtml(title) }],
421
- },
422
- ];
423
- if (version) {
424
- tree.push({
425
- type: "prose",
426
- html: `<p class="text-sm text-muted-foreground">Version ${escapeHtml(version)}</p>`,
427
- });
428
- }
429
- if (description) {
430
- tree.push({ type: "prose", html: paragraphHtml(description) });
431
- }
432
- return {
433
- slug: "index",
434
- title,
435
- tree,
436
- headings: [{ depth: 1, slug: "api-reference", text: title }],
437
- };
438
- }
439
- function buildNav(spec, pages, tagToOps, untagged, hrefPrefix) {
440
- const nav = [];
441
- const root = {
442
- label: (spec.info && spec.info.title) || "API Reference",
443
- href: hrefHref(hrefPrefix, "index"),
444
- };
445
- nav.push(root);
446
- // Use spec.tags[] order when present so authors control nav order.
447
- const declaredTagOrder = Array.isArray(spec.tags)
448
- ? spec.tags.map((t) => t?.name).filter(Boolean)
449
- : [];
450
- const seenTags = new Set();
451
- const tagOrder = [];
452
- for (const t of declaredTagOrder) {
453
- if (tagToOps.has(t)) {
454
- tagOrder.push(t);
455
- seenTags.add(t);
456
- }
457
- }
458
- for (const t of tagToOps.keys()) {
459
- if (!seenTags.has(t))
460
- tagOrder.push(t);
461
- }
462
- for (const tagName of tagOrder) {
463
- const ops = tagToOps.get(tagName) || [];
464
- const tagSlug = slugifyTag(tagName);
465
- nav.push({
466
- label: tagName,
467
- href: hrefHref(hrefPrefix, tagSlug),
468
- children: ops
469
- .slice()
470
- .sort(byMethodThenPath)
471
- .map((op) => ({
472
- label: op.title,
473
- href: hrefHref(hrefPrefix, op.slug),
474
- })),
475
- });
476
- }
477
- if (untagged.length > 0) {
478
- nav.push({
479
- label: "Other",
480
- children: untagged
481
- .slice()
482
- .sort(byMethodThenPath)
483
- .map((op) => ({
484
- label: op.title,
485
- href: hrefHref(hrefPrefix, op.slug),
486
- })),
487
- });
488
- }
489
- return nav;
490
- }
491
- function byMethodThenPath(a, b) {
492
- const m = a.method.localeCompare(b.method);
493
- if (m !== 0)
494
- return m;
495
- return a.path.localeCompare(b.path);
496
- }
497
- function hrefHref(prefix, slug) {
498
- const p = prefix ? `${prefix}/` : "/";
499
- return slug === "index" ? p : `${p}${slug}`;
500
- }
501
- function escapeHtml(s) {
502
- return s
503
- .replace(/&/g, "&amp;")
504
- .replace(/</g, "&lt;")
505
- .replace(/>/g, "&gt;");
506
- }
507
- function escapeAttr(s) {
508
- return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
509
- }
510
- function paragraphHtml(text) {
511
- return `<p>${escapeHtml(text)}</p>`;
512
- }
513
- //# sourceMappingURL=parse.js.map
package/dist/slug.d.ts CHANGED
@@ -13,4 +13,3 @@ export declare function operationToSlug(method: string, path: string, operationI
13
13
  * Tag "Pet Store" → "pet-store".
14
14
  */
15
15
  export declare function slugifyTag(tag: string): string;
16
- //# sourceMappingURL=slug.d.ts.map
package/dist/slug.js CHANGED
@@ -38,4 +38,3 @@ export function slugifyTag(tag) {
38
38
  .replace(/[^a-z0-9]+/g, "-")
39
39
  .replace(/^-+|-+$/g, "");
40
40
  }
41
- //# sourceMappingURL=slug.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The content-relative paths `:::openapi{spec}` on the page `slug` may mean,
3
+ * in the order to try them, or why it may name none.
4
+ *
5
+ * The spec path is CONTENT, so it is held to what content may name:
6
+ * - a file, never a URL: a page does not get to make the build fetch;
7
+ * - `.yaml`, `.yml` or `.json`;
8
+ * - inside a content root (the caller checks, after resolving symlinks).
9
+ *
10
+ * A leading `/` is the content root. A relative path is tried against the
11
+ * page's folder, then the page's own folder for an index page (slug
12
+ * `reference` may be `reference/index.md`). Importers write root-relative
13
+ * paths, which have no such ambiguity.
14
+ */
15
+ export declare function specCandidates(spec: string, slug: string): {
16
+ paths: string[];
17
+ } | {
18
+ error: string;
19
+ };
20
+ /** What a lookup found at a content-relative path: the file to read, a file outside the content, or nothing. */
21
+ export type SpecLookup = (path: string) => {
22
+ path: string;
23
+ } | "outside" | undefined;
24
+ /**
25
+ * The spec file `:::openapi{spec}` on the page `slug` names, or an error.
26
+ * `lookup` answers for one content-relative path (dogsbay-ssg: its file
27
+ * map; the CLI: each content root, symlinks resolved).
28
+ */
29
+ export declare function findSpec(spec: string, slug: string, lookup: SpecLookup): {
30
+ path: string;
31
+ } | {
32
+ error: string;
33
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which spec file a page's `:::openapi{spec}` may name. No dependencies, so
3
+ * a host can check a path without loading the spec parser
4
+ * (`@dogsbay/format-openapi/spec-path`); also exported from `./core`.
5
+ */
6
+ const SPEC_EXT = /\.(ya?ml|json)$/i;
7
+ /**
8
+ * The content-relative paths `:::openapi{spec}` on the page `slug` may mean,
9
+ * in the order to try them, or why it may name none.
10
+ *
11
+ * The spec path is CONTENT, so it is held to what content may name:
12
+ * - a file, never a URL: a page does not get to make the build fetch;
13
+ * - `.yaml`, `.yml` or `.json`;
14
+ * - inside a content root (the caller checks, after resolving symlinks).
15
+ *
16
+ * A leading `/` is the content root. A relative path is tried against the
17
+ * page's folder, then the page's own folder for an index page (slug
18
+ * `reference` may be `reference/index.md`). Importers write root-relative
19
+ * paths, which have no such ambiguity.
20
+ */
21
+ export function specCandidates(spec, slug) {
22
+ if (!spec)
23
+ return { error: "no `spec` on :::openapi" };
24
+ if (/^[a-z][a-z0-9+.-]*:/i.test(spec) || spec.startsWith("//")) {
25
+ return { error: `a page may name a spec file, not a URL (${spec})` };
26
+ }
27
+ if (/[?#]/.test(spec) || !SPEC_EXT.test(spec)) {
28
+ return { error: `not a .yaml, .yml or .json file (${spec})` };
29
+ }
30
+ const clean = spec.replace(/\\/g, "/");
31
+ if (clean.startsWith("/"))
32
+ return { paths: [normalizePath(clean)].filter(isContained) };
33
+ const dir = slug.includes("/") ? slug.slice(0, slug.lastIndexOf("/")) : "";
34
+ return { paths: [normalizePath(`${dir}/${clean}`), normalizePath(`${slug}/${clean}`)].filter(isContained) };
35
+ }
36
+ /**
37
+ * The spec file `:::openapi{spec}` on the page `slug` names, or an error.
38
+ * `lookup` answers for one content-relative path (dogsbay-ssg: its file
39
+ * map; the CLI: each content root, symlinks resolved).
40
+ */
41
+ export function findSpec(spec, slug, lookup) {
42
+ const c = specCandidates(spec, slug);
43
+ if ("error" in c)
44
+ return c;
45
+ let escaped = c.paths.length === 0;
46
+ for (const p of c.paths) {
47
+ const found = lookup(p);
48
+ if (found === "outside")
49
+ escaped = true;
50
+ else if (found)
51
+ return found;
52
+ }
53
+ return { error: escaped ? `the spec resolves outside the content root (${spec})` : `spec not found (${spec})` };
54
+ }
55
+ /** `a/./b/../c` → `a/c`; a path that climbs above the root keeps its leading `..`. */
56
+ function normalizePath(p) {
57
+ const out = [];
58
+ for (const seg of p.split("/")) {
59
+ if (!seg || seg === ".")
60
+ continue;
61
+ if (seg === ".." && out.length && out[out.length - 1] !== "..")
62
+ out.pop();
63
+ else
64
+ out.push(seg);
65
+ }
66
+ return out.join("/");
67
+ }
68
+ const isContained = (p) => p !== "" && p !== ".." && !p.startsWith("../");