@umami/shiso 1.11.0 → 1.12.0

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.
Files changed (39) hide show
  1. package/bin/shiso.mjs +20 -1
  2. package/dist/chunks/App.js +360 -26
  3. package/dist/chunks/docs.js +2 -2
  4. package/dist/entry-client.js +1 -1
  5. package/dist/entry-server.js +30 -2
  6. package/docs.schema.json +90 -0
  7. package/mdx.config.ts +13 -97
  8. package/package.json +5 -4
  9. package/scripts/build-runtime.mjs +1 -0
  10. package/scripts/check-content.mjs +358 -0
  11. package/scripts/expand-navigation-globs.mjs +208 -0
  12. package/scripts/expand-openapi-navigation.mjs +94 -0
  13. package/scripts/generate-openapi.mjs +117 -0
  14. package/scripts/generate-search-index.mjs +31 -1
  15. package/scripts/lib/openapi.mjs +653 -0
  16. package/scripts/load-docs-config.mjs +52 -6
  17. package/scripts/load-shiso-config.mjs +45 -3
  18. package/scripts/prerender.mjs +83 -3
  19. package/scripts/vite-docs-config.mjs +27 -6
  20. package/src/App.tsx +0 -1
  21. package/src/components/CodeBlock.tsx +72 -8
  22. package/src/components/DocContent.tsx +18 -0
  23. package/src/components/Docs.tsx +6 -1
  24. package/src/components/OpenApiOperation.tsx +197 -0
  25. package/src/components/SideNav.tsx +8 -2
  26. package/src/components/docs/CodeGroup.tsx +6 -2
  27. package/src/entry-server.tsx +50 -0
  28. package/src/lib/code-blocks.ts +18 -0
  29. package/src/lib/code-meta.ts +87 -0
  30. package/src/lib/docs-config.ts +4 -1
  31. package/src/lib/openapi.generated.ts +4 -0
  32. package/src/lib/openapi.ts +63 -0
  33. package/src/lib/rehype-shiki.ts +196 -0
  34. package/src/lib/site-model.ts +2 -0
  35. package/src/lib/types.ts +116 -2
  36. package/src/styles/global.css +63 -73
  37. package/types/config.d.ts +11 -4
  38. package/vite.config.ts +49 -3
  39. package/CHANGELOG.md +0 -171
@@ -0,0 +1,653 @@
1
+ /**
2
+ * OpenAPI 3.x spec loading and normalization.
3
+ *
4
+ * Parses a local JSON or YAML spec, resolves in-document $refs with a cycle
5
+ * guard, and normalizes each operation into the serializable shape consumed by
6
+ * the OpenApiOperation component, the search indexer, and the markdown export.
7
+ * Only this module understands raw OpenAPI documents; everything downstream
8
+ * works with NormalizedOperation objects.
9
+ */
10
+
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { parse as parseYaml } from 'yaml';
14
+ import { slugify } from './slug.mjs';
15
+
16
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
17
+ const MAX_DEPTH = 8;
18
+ const MAX_CHILDREN = 100;
19
+ const FALLBACK_SERVER = 'https://api.example.com';
20
+
21
+ export const DEFAULT_API_DIRECTORY = 'api-reference';
22
+
23
+ export async function loadOpenApiSpec({ root, specPath }) {
24
+ const projectRoot = path.resolve(root);
25
+ const resolvedPath = path.resolve(projectRoot, specPath);
26
+
27
+ if (!resolvedPath.startsWith(projectRoot + path.sep)) {
28
+ throw new Error(`OpenAPI spec "${specPath}" must live inside the project root.`);
29
+ }
30
+
31
+ let source;
32
+ try {
33
+ source = await fs.readFile(resolvedPath, 'utf8');
34
+ } catch {
35
+ throw new Error(
36
+ `OpenAPI spec "${specPath}" was not found. The api.spec path resolves against the project root.`,
37
+ );
38
+ }
39
+
40
+ let spec;
41
+ try {
42
+ // YAML is a superset of JSON, so one parser covers both formats.
43
+ spec = parseYaml(source);
44
+ } catch (error) {
45
+ throw new Error(`Could not parse OpenAPI spec "${specPath}": ${error.message}`);
46
+ }
47
+
48
+ if (!spec || typeof spec !== 'object' || typeof spec.openapi !== 'string') {
49
+ throw new Error(
50
+ `"${specPath}" is not an OpenAPI document: missing the "openapi" version field.`,
51
+ );
52
+ }
53
+ if (!spec.openapi.startsWith('3.')) {
54
+ throw new Error(
55
+ `Unsupported OpenAPI version "${spec.openapi}" in "${specPath}": Shiso supports OpenAPI 3.0 and 3.1.`,
56
+ );
57
+ }
58
+
59
+ return { spec, specPath: resolvedPath };
60
+ }
61
+
62
+ function resolveRef(spec, ref) {
63
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) {
64
+ throw new Error(`Only in-document $refs are supported, found "${ref}".`);
65
+ }
66
+
67
+ return ref
68
+ .slice(2)
69
+ .split('/')
70
+ .reduce((node, segment) => {
71
+ const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
72
+ if (node == null || typeof node !== 'object' || !(key in node)) {
73
+ throw new Error(`Unresolvable $ref "${ref}".`);
74
+ }
75
+ return node[key];
76
+ }, spec);
77
+ }
78
+
79
+ function deref(spec, node) {
80
+ return node?.$ref ? resolveRef(spec, node.$ref) : node;
81
+ }
82
+
83
+ function baseType(schema) {
84
+ // OpenAPI 3.1 allows type arrays such as ["string", "null"].
85
+ if (Array.isArray(schema.type)) {
86
+ const types = schema.type.filter(value => value !== 'null');
87
+ const label = types.join(' | ') || 'any';
88
+ return schema.type.includes('null') ? `${label} | null` : label;
89
+ }
90
+
91
+ const label = schema.type || (schema.properties ? 'object' : 'any');
92
+ return schema.nullable === true ? `${label} | null` : label;
93
+ }
94
+
95
+ function typeLabel(spec, schema) {
96
+ if (schema.enum) {
97
+ return `enum<${baseType(schema)}>`;
98
+ }
99
+ if (schema.oneOf || schema.anyOf) {
100
+ return 'oneOf';
101
+ }
102
+ if (schema.type === 'array' || (Array.isArray(schema.type) && schema.type.includes('array'))) {
103
+ const items = schema.items ? deref(spec, schema.items) : undefined;
104
+ const itemLabel = items
105
+ ? schema.items.$ref
106
+ ? schema.items.$ref.split('/').pop()
107
+ : typeLabel(spec, items)
108
+ : 'any';
109
+ return `${itemLabel}[]`;
110
+ }
111
+ return baseType(schema);
112
+ }
113
+
114
+ function stringifyValue(value) {
115
+ return typeof value === 'string' ? value : JSON.stringify(value);
116
+ }
117
+
118
+ /** Builds the recursive display tree for a schema, guarding against cycles. */
119
+ export function schemaTree(spec, schema, { name, required, seen = new Set(), depth = 0 } = {}) {
120
+ if (!schema || typeof schema !== 'object') {
121
+ return { name, type: 'any', required };
122
+ }
123
+
124
+ if (schema.$ref) {
125
+ const refName = schema.$ref.split('/').pop();
126
+ if (seen.has(schema.$ref) || depth >= MAX_DEPTH) {
127
+ return { name, type: `${refName} (circular)`, required };
128
+ }
129
+ return schemaTree(spec, resolveRef(spec, schema.$ref), {
130
+ name,
131
+ required,
132
+ seen: new Set([...seen, schema.$ref]),
133
+ depth,
134
+ });
135
+ }
136
+
137
+ if (schema.allOf) {
138
+ const merged = {};
139
+ for (const member of schema.allOf) {
140
+ Object.assign(merged, deref(spec, member));
141
+ }
142
+ merged.description = schema.description || merged.description;
143
+ return schemaTree(spec, merged, { name, required, seen, depth });
144
+ }
145
+
146
+ const node = { name, type: typeLabel(spec, schema), required };
147
+
148
+ if (schema.deprecated === true) node.deprecated = true;
149
+ if (typeof schema.description === 'string') node.description = schema.description;
150
+ if (schema.default !== undefined) node.default = stringifyValue(schema.default);
151
+ if (schema.enum) node.enum = schema.enum.map(stringifyValue);
152
+
153
+ const variants = schema.oneOf || schema.anyOf;
154
+ if (variants) {
155
+ node.children = variants.slice(0, MAX_CHILDREN).map((variant, index) =>
156
+ schemaTree(spec, variant, {
157
+ name: variant.$ref ? variant.$ref.split('/').pop() : `option ${index + 1}`,
158
+ seen,
159
+ depth: depth + 1,
160
+ }),
161
+ );
162
+ return node;
163
+ }
164
+
165
+ const items = schema.type === 'array' ? schema.items : undefined;
166
+ if (items && (seen.has(items.$ref) || depth >= MAX_DEPTH)) {
167
+ return node;
168
+ }
169
+ const target = items ? deref(spec, items) : schema;
170
+
171
+ if (target?.properties && depth < MAX_DEPTH) {
172
+ const requiredKeys = new Set(Array.isArray(target.required) ? target.required : []);
173
+ const nextSeen = items?.$ref ? new Set([...seen, items.$ref]) : seen;
174
+ node.children = Object.entries(target.properties)
175
+ .slice(0, MAX_CHILDREN)
176
+ .map(([key, property]) =>
177
+ schemaTree(spec, property, {
178
+ name: key,
179
+ required: requiredKeys.has(key) || undefined,
180
+ seen: nextSeen,
181
+ depth: depth + 1,
182
+ }),
183
+ );
184
+ }
185
+
186
+ return node;
187
+ }
188
+
189
+ /** Derives an example value when the spec provides none. */
190
+ export function exampleFromSchema(spec, schema, seen = new Set(), depth = 0) {
191
+ if (!schema || typeof schema !== 'object' || depth >= MAX_DEPTH) return null;
192
+
193
+ if (schema.$ref) {
194
+ if (seen.has(schema.$ref)) return null;
195
+ return exampleFromSchema(
196
+ spec,
197
+ resolveRef(spec, schema.$ref),
198
+ new Set([...seen, schema.$ref]),
199
+ depth,
200
+ );
201
+ }
202
+ if (schema.example !== undefined) return schema.example;
203
+ if (Array.isArray(schema.examples) && schema.examples.length) return schema.examples[0];
204
+ if (schema.default !== undefined) return schema.default;
205
+ if (schema.enum?.length) return schema.enum[0];
206
+ if (schema.allOf) {
207
+ const merged = {};
208
+ for (const member of schema.allOf) {
209
+ Object.assign(merged, exampleFromSchema(spec, member, seen, depth) || {});
210
+ }
211
+ return merged;
212
+ }
213
+ if (schema.oneOf || schema.anyOf) {
214
+ return exampleFromSchema(spec, (schema.oneOf || schema.anyOf)[0], seen, depth);
215
+ }
216
+
217
+ const type = Array.isArray(schema.type)
218
+ ? schema.type.find(value => value !== 'null')
219
+ : schema.type;
220
+
221
+ switch (type) {
222
+ case 'string':
223
+ if (schema.format === 'date-time') return '2024-01-15T09:30:00Z';
224
+ if (schema.format === 'date') return '2024-01-15';
225
+ if (schema.format === 'email') return 'user@example.com';
226
+ if (schema.format === 'uuid') return '123e4567-e89b-12d3-a456-426614174000';
227
+ if (schema.format === 'uri') return 'https://example.com';
228
+ return 'string';
229
+ case 'integer':
230
+ case 'number':
231
+ return schema.minimum ?? 0;
232
+ case 'boolean':
233
+ return true;
234
+ case 'array': {
235
+ const item = exampleFromSchema(spec, schema.items, seen, depth + 1);
236
+ return item === null ? [] : [item];
237
+ }
238
+ default: {
239
+ if (!schema.properties) return type ? null : {};
240
+ const value = {};
241
+ for (const [key, property] of Object.entries(schema.properties).slice(0, MAX_CHILDREN)) {
242
+ value[key] = exampleFromSchema(spec, property, seen, depth + 1);
243
+ }
244
+ return value;
245
+ }
246
+ }
247
+ }
248
+
249
+ function pickContent(content) {
250
+ if (!content || typeof content !== 'object') return undefined;
251
+ const contentType = 'application/json' in content ? 'application/json' : Object.keys(content)[0];
252
+ return contentType ? { contentType, media: content[contentType] } : undefined;
253
+ }
254
+
255
+ function mediaExample(spec, media) {
256
+ if (!media) return undefined;
257
+ if (media.example !== undefined) return media.example;
258
+ const named = media.examples && Object.values(media.examples)[0];
259
+ if (named) {
260
+ const resolved = deref(spec, named);
261
+ if (resolved?.value !== undefined) return resolved.value;
262
+ }
263
+ if (media.schema) {
264
+ return exampleFromSchema(spec, media.schema) ?? undefined;
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ function formatExample(value) {
270
+ return value === undefined ? undefined : JSON.stringify(value, null, 2);
271
+ }
272
+
273
+ function securitySummaries(spec, operation) {
274
+ const requirements = operation.security ?? spec.security ?? [];
275
+ const schemes = spec.components?.securitySchemes || {};
276
+ const names = new Set();
277
+
278
+ for (const requirement of requirements) {
279
+ for (const name of Object.keys(requirement || {})) {
280
+ const scheme = deref(spec, schemes[name]);
281
+ names.add(
282
+ scheme ? `${name} (${[scheme.type, scheme.scheme].filter(Boolean).join(' ')})` : name,
283
+ );
284
+ }
285
+ }
286
+
287
+ return [...names];
288
+ }
289
+
290
+ function parameterNode(spec, parameter) {
291
+ const resolved = deref(spec, parameter);
292
+ const node = schemaTree(spec, resolved.schema || {}, {
293
+ name: resolved.name,
294
+ required: resolved.required === true || resolved.in === 'path' || undefined,
295
+ });
296
+ if (resolved.description && !node.description) node.description = resolved.description;
297
+ if (resolved.deprecated === true) node.deprecated = true;
298
+ return { location: resolved.in, node, example: exampleFromSchema(spec, resolved.schema || {}) };
299
+ }
300
+
301
+ /** Normalizes every operation in the spec into a serializable shape. */
302
+ export function normalizeOperations(spec) {
303
+ const operations = [];
304
+
305
+ for (const [pathName, pathItem] of Object.entries(spec.paths || {})) {
306
+ const resolvedPath = deref(spec, pathItem);
307
+ if (!resolvedPath || typeof resolvedPath !== 'object') continue;
308
+
309
+ for (const method of METHODS) {
310
+ const operation = resolvedPath[method];
311
+ if (!operation || typeof operation !== 'object') continue;
312
+
313
+ const upper = method.toUpperCase();
314
+ const parameters = { query: [], path: [], header: [], cookie: [] };
315
+ const merged = [...(resolvedPath.parameters || []), ...(operation.parameters || [])];
316
+ const seenParams = new Set();
317
+ const paramExamples = {};
318
+
319
+ // Operation-level parameters override path-level ones with the same
320
+ // name and location, so walk the merged list from the end.
321
+ for (const parameter of merged.reverse()) {
322
+ const { location, node, example } = parameterNode(spec, parameter);
323
+ const dedupeKey = `${location}:${node.name}`;
324
+ if (!parameters[location] || seenParams.has(dedupeKey)) continue;
325
+ seenParams.add(dedupeKey);
326
+ parameters[location].unshift(node);
327
+ if (example !== null && example !== undefined) paramExamples[dedupeKey] = example;
328
+ }
329
+
330
+ const bodySource = deref(spec, operation.requestBody);
331
+ const bodyContent = pickContent(bodySource?.content);
332
+ const requestBody = bodyContent?.media?.schema
333
+ ? {
334
+ required: bodySource.required === true || undefined,
335
+ contentType: bodyContent.contentType,
336
+ schema: schemaTree(spec, bodyContent.media.schema),
337
+ example: formatExample(mediaExample(spec, bodyContent.media)),
338
+ }
339
+ : undefined;
340
+
341
+ const responses = Object.entries(operation.responses || {})
342
+ .sort(([left], [right]) => left.localeCompare(right))
343
+ .map(([status, response]) => {
344
+ const resolved = deref(spec, response);
345
+ const content = pickContent(resolved?.content);
346
+ return {
347
+ status,
348
+ description: resolved?.description || undefined,
349
+ contentType: content?.contentType,
350
+ schema: content?.media?.schema ? schemaTree(spec, content.media.schema) : undefined,
351
+ example: formatExample(mediaExample(spec, content?.media)),
352
+ };
353
+ });
354
+
355
+ const serverUrl = operation.servers?.[0]?.url || spec.servers?.[0]?.url || FALLBACK_SERVER;
356
+ const normalized = {
357
+ id: slugify(
358
+ operation.operationId || `${method}-${pathName}`,
359
+ `${method}-${slugify(pathName, 'root')}`,
360
+ ),
361
+ key: `${upper} ${pathName}`,
362
+ method: upper,
363
+ path: pathName,
364
+ summary: operation.summary || undefined,
365
+ description: operation.description || undefined,
366
+ tags: operation.tags?.length ? operation.tags : ['default'],
367
+ deprecated: operation.deprecated === true || undefined,
368
+ parameters,
369
+ requestBody,
370
+ responses,
371
+ security: securitySummaries(spec, operation),
372
+ serverUrl,
373
+ samples: [],
374
+ };
375
+
376
+ normalized.samples = buildCodeSamples(normalized, paramExamples);
377
+ operations.push(normalized);
378
+ }
379
+ }
380
+
381
+ const ids = new Set();
382
+ for (const operation of operations) {
383
+ let candidate = operation.id;
384
+ let counter = 2;
385
+ while (ids.has(candidate)) {
386
+ candidate = `${operation.id}-${counter}`;
387
+ counter += 1;
388
+ }
389
+ operation.id = candidate;
390
+ ids.add(candidate);
391
+ }
392
+
393
+ return operations;
394
+ }
395
+
396
+ function queryString(operation, paramExamples) {
397
+ const pairs = operation.parameters.query
398
+ .filter(parameter => parameter.required)
399
+ .map(parameter => {
400
+ const example = paramExamples[`query:${parameter.name}`];
401
+ return `${parameter.name}=${encodeURIComponent(String(example ?? ''))}`;
402
+ });
403
+ return pairs.length ? `?${pairs.join('&')}` : '';
404
+ }
405
+
406
+ function pythonLiteral(json) {
407
+ return json
408
+ .replace(/"([^"]+)":/g, "'$1':")
409
+ .replace(/"/g, "'")
410
+ .replace(/\btrue\b/g, 'True')
411
+ .replace(/\bfalse\b/g, 'False')
412
+ .replace(/\bnull\b/g, 'None');
413
+ }
414
+
415
+ /** Builds curl, JavaScript, and Python request samples for an operation. */
416
+ export function buildCodeSamples(operation, paramExamples = {}) {
417
+ const url = `${operation.serverUrl.replace(/\/$/, '')}${operation.path}${queryString(operation, paramExamples)}`;
418
+ const hasAuth = operation.security.length > 0;
419
+ const body = operation.requestBody?.example;
420
+ const method = operation.method;
421
+
422
+ const escapedBody = body ? body.replace(/'/g, `'\\''`) : undefined;
423
+ const curl = [
424
+ `curl -X ${method} '${url}'`,
425
+ ...(hasAuth ? [` -H 'Authorization: Bearer <token>'`] : []),
426
+ ...(body ? [` -H 'Content-Type: application/json'`, ` -d '${escapedBody}'`] : []),
427
+ ].join(' \\\n');
428
+
429
+ const headers = [
430
+ ...(body ? [` 'Content-Type': 'application/json',`] : []),
431
+ ...(hasAuth ? [` Authorization: 'Bearer <token>',`] : []),
432
+ ];
433
+ const javascript = [
434
+ `const response = await fetch('${url}', {`,
435
+ ` method: '${method}',`,
436
+ ...(headers.length ? [' headers: {', ...headers, ' },'] : []),
437
+ ...(body ? [` body: JSON.stringify(${body}),`] : []),
438
+ '});',
439
+ 'const data = await response.json();',
440
+ ].join('\n');
441
+
442
+ const python = [
443
+ 'import requests',
444
+ '',
445
+ `response = requests.${method.toLowerCase()}(`,
446
+ ` '${url}',`,
447
+ ...(hasAuth ? [` headers={'Authorization': 'Bearer <token>'},`] : []),
448
+ ...(body ? [` json=${pythonLiteral(body)},`] : []),
449
+ ')',
450
+ 'print(response.json())',
451
+ ].join('\n');
452
+
453
+ return [
454
+ { language: 'bash', label: 'cURL', source: curl },
455
+ { language: 'javascript', label: 'JavaScript', source: javascript },
456
+ { language: 'python', label: 'Python', source: python },
457
+ ];
458
+ }
459
+
460
+ function markdownSchemaLines(node, depth = 0) {
461
+ if (!node) return [];
462
+ const indent = ' '.repeat(depth);
463
+ const suffix = node.required ? ', required' : '';
464
+ const lines = [`${indent}- \`${node.name || 'body'}\` (${node.type}${suffix})`];
465
+ if (node.description) lines[0] += ` — ${node.description.split('\n')[0]}`;
466
+ for (const child of node.children || []) {
467
+ lines.push(...markdownSchemaLines(child, depth + 1));
468
+ }
469
+ return lines;
470
+ }
471
+
472
+ /** Renders an operation as markdown for the .md export and llms-full.txt. */
473
+ export function operationToMarkdown(operation) {
474
+ const lines = [`## ${operation.method} ${operation.path}`, ''];
475
+
476
+ if (operation.summary) lines.push(operation.summary, '');
477
+ if (operation.description) lines.push(operation.description, '');
478
+
479
+ const allParameters = ['path', 'query', 'header', 'cookie'].flatMap(location =>
480
+ operation.parameters[location].map(parameter => ({ location, parameter })),
481
+ );
482
+ if (allParameters.length) {
483
+ lines.push('### Parameters', '');
484
+ for (const { location, parameter } of allParameters) {
485
+ const detail = [location, parameter.type, parameter.required ? 'required' : '']
486
+ .filter(Boolean)
487
+ .join(', ');
488
+ const description = parameter.description ? ` — ${parameter.description.split('\n')[0]}` : '';
489
+ lines.push(`- \`${parameter.name}\` (${detail})${description}`);
490
+ }
491
+ lines.push('');
492
+ }
493
+
494
+ if (operation.requestBody) {
495
+ lines.push('### Request body', '', ...markdownSchemaLines(operation.requestBody.schema), '');
496
+ if (operation.requestBody.example) {
497
+ lines.push('```json', operation.requestBody.example, '```', '');
498
+ }
499
+ }
500
+
501
+ if (operation.responses.length) {
502
+ lines.push('### Responses', '');
503
+ for (const response of operation.responses) {
504
+ const description = response.description ? ` — ${response.description}` : '';
505
+ lines.push(`#### ${response.status}${description}`, '');
506
+ if (response.example) {
507
+ lines.push('```json', response.example, '```', '');
508
+ }
509
+ }
510
+ }
511
+
512
+ if (operation.samples.length) {
513
+ lines.push('### Code samples', '');
514
+ for (const sample of operation.samples) {
515
+ lines.push(`**${sample.label}**`, '', `\`\`\`${sample.language}`, sample.source, '```', '');
516
+ }
517
+ }
518
+
519
+ return lines.join('\n').trim();
520
+ }
521
+
522
+ function yamlString(value) {
523
+ return JSON.stringify(String(value).split('\n')[0]);
524
+ }
525
+
526
+ /**
527
+ * Writes one stub .mdx page per operation, skipping files that already exist
528
+ * so authors can customize titles or add prose above the generated reference.
529
+ */
530
+ export async function generateOpenApiStubs({ root, contentDir, directory, operations }) {
531
+ const target = path.join(path.resolve(root), contentDir, directory);
532
+ await fs.mkdir(target, { recursive: true });
533
+ const created = [];
534
+
535
+ for (const operation of operations) {
536
+ const filePath = path.join(target, `${operation.id}.mdx`);
537
+
538
+ try {
539
+ await fs.access(filePath);
540
+ continue;
541
+ } catch {
542
+ // Missing: generate it.
543
+ }
544
+
545
+ const frontmatter = [
546
+ '---',
547
+ `title: ${yamlString(operation.summary || `${operation.method} ${operation.path}`)}`,
548
+ ...(operation.description ? [`description: ${yamlString(operation.description)}`] : []),
549
+ `openapi: ${operation.method} ${operation.path}`,
550
+ '---',
551
+ '',
552
+ ].join('\n');
553
+
554
+ await fs.writeFile(filePath, frontmatter);
555
+ created.push(`${directory}/${operation.id}`);
556
+ }
557
+
558
+ return created;
559
+ }
560
+
561
+ /** Sanitizes the api.directory setting into a relative path inside contentDir. */
562
+ export function resolveApiDirectory(api) {
563
+ const raw =
564
+ typeof api?.directory === 'string' && api.directory.trim()
565
+ ? api.directory
566
+ : DEFAULT_API_DIRECTORY;
567
+ const directory = raw
568
+ .trim()
569
+ .replace(/\\/g, '/')
570
+ .replace(/^\.\//, '')
571
+ .replace(/^\/+|\/+$/g, '');
572
+
573
+ if (!directory || directory.split('/').includes('..')) {
574
+ throw new Error(
575
+ `Invalid api.directory "${raw}": use a relative path inside the content directory.`,
576
+ );
577
+ }
578
+
579
+ return directory;
580
+ }
581
+
582
+ /** True when the operation renders a Parameters section (incl. auth). */
583
+ export function hasOperationParameters(operation) {
584
+ const { query, path: pathParams, header, cookie } = operation.parameters;
585
+ return (
586
+ query.length + pathParams.length + header.length + cookie.length > 0 ||
587
+ operation.security.length > 0
588
+ );
589
+ }
590
+
591
+ /**
592
+ * Anchor ids for the generated sections, in render order. Mirrors
593
+ * operationSections in src/lib/openapi.ts (asserted by tests/openapi.test.mjs).
594
+ */
595
+ export function operationAnchors(operation) {
596
+ return [
597
+ ...(hasOperationParameters(operation) ? ['parameters'] : []),
598
+ ...(operation.requestBody ? ['request-body'] : []),
599
+ ...(operation.responses.length ? ['responses'] : []),
600
+ ...(operation.samples.length ? ['code-samples'] : []),
601
+ ];
602
+ }
603
+
604
+ function schemaText(node) {
605
+ if (!node) return '';
606
+ return [node.name, node.description, ...(node.children || []).map(schemaText)]
607
+ .filter(Boolean)
608
+ .join(' ');
609
+ }
610
+
611
+ /** Normalizes an openapi frontmatter value into the operation lookup key. */
612
+ export function normalizeOperationKey(value) {
613
+ if (typeof value !== 'string' || !value.trim()) return undefined;
614
+ const [method, ...rest] = value.trim().split(/\s+/);
615
+ return `${method.toUpperCase()} ${rest.join(' ')}`;
616
+ }
617
+
618
+ /** Search-index sections for an operation, matching operationAnchors ids. */
619
+ export function operationSearchSections(operation) {
620
+ const allParameters = [
621
+ ...operation.parameters.path,
622
+ ...operation.parameters.query,
623
+ ...operation.parameters.header,
624
+ ...operation.parameters.cookie,
625
+ ];
626
+
627
+ return [
628
+ {
629
+ heading: undefined,
630
+ id: undefined,
631
+ text: [operation.method, operation.path, operation.summary, operation.description]
632
+ .filter(Boolean)
633
+ .join(' '),
634
+ },
635
+ {
636
+ heading: 'Parameters',
637
+ id: 'parameters',
638
+ text: allParameters.map(schemaText).join(' '),
639
+ },
640
+ {
641
+ heading: 'Request body',
642
+ id: 'request-body',
643
+ text: schemaText(operation.requestBody?.schema),
644
+ },
645
+ {
646
+ heading: 'Responses',
647
+ id: 'responses',
648
+ text: operation.responses
649
+ .map(response => [response.status, response.description].filter(Boolean).join(' '))
650
+ .join(' '),
651
+ },
652
+ ].filter(section => section.text.replace(/\s+/g, ' ').trim());
653
+ }