@umami/shiso 1.18.0 → 1.19.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.
- package/dist/chunks/App.js +1081 -47
- package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
- package/dist/chunks/chunk-GMAD6QVW.js +1 -1
- package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
- package/dist/chunks/dist.js +1 -1
- package/dist/chunks/docs.js +183 -8
- package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
- package/dist/chunks/src.js +1 -1
- package/dist/components.js +1 -1
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +1 -1
- package/docs.schema.json +1164 -1135
- package/package.json +1 -2
- package/scripts/check-content.mjs +42 -15
- package/scripts/expand-openapi-navigation.mjs +47 -21
- package/scripts/generate-openapi.mjs +36 -11
- package/scripts/generate-search-index.mjs +19 -18
- package/scripts/lib/openapi-project.mjs +197 -0
- package/scripts/lib/openapi.mjs +257 -111
- package/scripts/lib/request-samples.mjs +323 -0
- package/scripts/load-docs-config.mjs +23 -15
- package/scripts/prerender.mjs +17 -14
- package/scripts/vite-docs-config.mjs +1 -0
- package/src/components/ApiPlayground.tsx +522 -0
- package/src/components/DocContent.tsx +15 -4
- package/src/components/Docs.tsx +18 -3
- package/src/components/LanguageSwitcher.tsx +26 -30
- package/src/components/OpenApiOperation.tsx +58 -12
- package/src/components/OpenApiSchema.tsx +97 -0
- package/src/components/SideNav.tsx +2 -2
- package/src/lib/openapi.generated.ts +2 -1
- package/src/lib/openapi.ts +104 -10
- package/src/lib/site-model.ts +12 -0
- package/src/lib/translations/de.json +26 -1
- package/src/lib/translations/en.json +26 -1
- package/src/lib/translations/es.json +26 -1
- package/src/lib/translations/fr.json +26 -1
- package/src/lib/translations/ja.json +26 -1
- package/src/lib/translations/zh-Hans.json +26 -1
- package/src/lib/translations/zh-Hant.json +26 -1
- package/src/lib/types.ts +89 -5
- package/types/labels.d.ts +25 -0
- package/vite.config.ts +3 -4
- package/CHANGELOG.md +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umami/shiso",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "Open-source documentation framework for Markdown and MDX sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"docs.schema.json",
|
|
39
39
|
"mdx.config.ts",
|
|
40
40
|
"vite.config.ts",
|
|
41
|
-
"CHANGELOG.md",
|
|
42
41
|
"README.md"
|
|
43
42
|
],
|
|
44
43
|
"scripts": {
|
|
@@ -13,12 +13,14 @@ import remarkMdx from 'remark-mdx';
|
|
|
13
13
|
import remarkParse from 'remark-parse';
|
|
14
14
|
import { unified } from 'unified';
|
|
15
15
|
import { headingText } from './lib/mdast.mjs';
|
|
16
|
+
import { hasPlayground, operationAnchors, schemaAnchors } from './lib/openapi.mjs';
|
|
16
17
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
isAmbiguousOperation,
|
|
19
|
+
isAmbiguousSchema,
|
|
20
|
+
loadApiProject,
|
|
21
|
+
lookupOperation,
|
|
22
|
+
lookupSchema,
|
|
23
|
+
} from './lib/openapi-project.mjs';
|
|
22
24
|
import { createSlugger } from './lib/slug.mjs';
|
|
23
25
|
import { loadDocsConfig } from './load-docs-config.mjs';
|
|
24
26
|
import { loadShisoConfig } from './load-shiso-config.mjs';
|
|
@@ -157,7 +159,18 @@ function inspectMarkdown(source, filePath) {
|
|
|
157
159
|
targets,
|
|
158
160
|
title: /^title\s*:/m.test(frontmatter),
|
|
159
161
|
description: /^description\s*:/m.test(frontmatter),
|
|
160
|
-
openapi: frontmatter
|
|
162
|
+
openapi: frontmatter
|
|
163
|
+
.match(/^openapi:\s*(.+)$/m)?.[1]
|
|
164
|
+
?.trim()
|
|
165
|
+
.replace(/^["']|["']$/g, ''),
|
|
166
|
+
openapiSchema: frontmatter
|
|
167
|
+
.match(/^openapi-schema:\s*(.+)$/m)?.[1]
|
|
168
|
+
?.trim()
|
|
169
|
+
.replace(/^["']|["']$/g, ''),
|
|
170
|
+
playground: frontmatter
|
|
171
|
+
.match(/^playground:\s*(.+)$/m)?.[1]
|
|
172
|
+
?.trim()
|
|
173
|
+
.replace(/^["']|["']$/g, ''),
|
|
161
174
|
};
|
|
162
175
|
}
|
|
163
176
|
|
|
@@ -256,25 +269,39 @@ export async function checkContent({ root = process.cwd(), config, shiso } = {})
|
|
|
256
269
|
|
|
257
270
|
// Generated OpenAPI sections render at runtime, so their anchors come from
|
|
258
271
|
// the spec rather than from markdown headings.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
openApiByKey = new Map(normalizeOperations(spec).map(operation => [operation.key, operation]));
|
|
263
|
-
}
|
|
272
|
+
const project = docsConfig.api?.spec
|
|
273
|
+
? await loadApiProject({ root: projectRoot, api: docsConfig.api })
|
|
274
|
+
: undefined;
|
|
264
275
|
|
|
265
276
|
const documents = new Map();
|
|
266
277
|
for (const page of pages) {
|
|
267
278
|
const source = await fs.readFile(page.filePath, 'utf8');
|
|
268
279
|
const document = inspectMarkdown(source, page.filePath);
|
|
269
|
-
const
|
|
270
|
-
const operation =
|
|
280
|
+
const relative = path.relative(projectRoot, page.filePath).replace(/\\/g, '/');
|
|
281
|
+
const operation = project ? lookupOperation(project, document.openapi) : undefined;
|
|
282
|
+
const schema = project ? lookupSchema(project, document.openapiSchema) : undefined;
|
|
271
283
|
if (operation) {
|
|
272
|
-
|
|
284
|
+
const playground = !operation.webhook && hasPlayground(docsConfig.api, document.playground);
|
|
285
|
+
for (const anchor of operationAnchors(operation, { playground })) {
|
|
273
286
|
document.anchors.add(anchor);
|
|
274
287
|
}
|
|
288
|
+
} else if (document.openapi) {
|
|
289
|
+
errors.push(
|
|
290
|
+
project && isAmbiguousOperation(project, document.openapi)
|
|
291
|
+
? `${relative} binds "openapi: ${document.openapi}", which several specs define; prefix it with the spec, e.g. "openapi: ${project.specs[0].id} ${document.openapi}".`
|
|
292
|
+
: `${relative} binds "openapi: ${document.openapi}", which matches no operation in ${project ? 'the API spec' : 'an API spec (docs.json has no api.spec)'}.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (schema) {
|
|
296
|
+
for (const anchor of schemaAnchors(schema)) document.anchors.add(anchor);
|
|
297
|
+
} else if (document.openapiSchema) {
|
|
298
|
+
errors.push(
|
|
299
|
+
project && isAmbiguousSchema(project, document.openapiSchema)
|
|
300
|
+
? `${relative} binds "openapi-schema: ${document.openapiSchema}", which several specs define; prefix it with the spec.`
|
|
301
|
+
: `${relative} binds "openapi-schema: ${document.openapiSchema}", which matches no schema in ${project ? 'the API spec' : 'an API spec (docs.json has no api.spec)'}.`,
|
|
302
|
+
);
|
|
275
303
|
}
|
|
276
304
|
documents.set(page.filePath, document);
|
|
277
|
-
const relative = path.relative(projectRoot, page.filePath).replace(/\\/g, '/');
|
|
278
305
|
if (!document.title) warnings.push(`${relative} has no frontmatter title.`);
|
|
279
306
|
if (!document.description) warnings.push(`${relative} has no frontmatter description.`);
|
|
280
307
|
}
|
|
@@ -26,48 +26,74 @@ export function hasOpenApiItems(navigation) {
|
|
|
26
26
|
return Object.values(navigation).some(hasOpenApiItems);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const WEBHOOKS_GROUP = 'Webhooks';
|
|
30
|
+
|
|
29
31
|
function pageEntry(operation, directory) {
|
|
30
32
|
return {
|
|
31
|
-
page: `${directory}/${operation.id}`,
|
|
32
|
-
title:
|
|
33
|
-
|
|
33
|
+
page: operation.pageRef || `${directory}/${operation.id}`,
|
|
34
|
+
title:
|
|
35
|
+
operation.summary ||
|
|
36
|
+
(operation.webhook ? operation.path : `${operation.method} ${operation.path}`),
|
|
37
|
+
method: operation.webhook ? 'WEBHOOK' : operation.method,
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/** Untagged webhooks collect under a "Webhooks" group; tagged ones join their tag. */
|
|
42
|
+
function operationTags(operation) {
|
|
43
|
+
const tagged = operation.tags.filter(tag => tag !== 'default');
|
|
44
|
+
if (tagged.length) return tagged;
|
|
45
|
+
return operation.webhook ? [WEBHOOKS_GROUP] : operation.tags;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tagGroups(operations, directory) {
|
|
49
|
+
const tags = [];
|
|
50
|
+
for (const operation of operations) {
|
|
51
|
+
for (const tag of operationTags(operation)) {
|
|
52
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return tags.map(tag => ({
|
|
56
|
+
group: tag,
|
|
57
|
+
pages: sortOperations(
|
|
58
|
+
operations.filter(operation => operationTags(operation).includes(tag)),
|
|
59
|
+
).map(operation => pageEntry(operation, directory)),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
function sortOperations(operations) {
|
|
38
64
|
return [...operations].sort(
|
|
39
65
|
(left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method),
|
|
40
66
|
);
|
|
41
67
|
}
|
|
42
68
|
|
|
43
|
-
function expandItem(item, { operations, directory }) {
|
|
69
|
+
function expandItem(item, { operations, directory, specs = [] }) {
|
|
44
70
|
assertOpenApiItem(item);
|
|
45
71
|
|
|
46
72
|
if (item.openapi === true) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}));
|
|
73
|
+
return tagGroups(operations, directory);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const value = item.openapi.trim();
|
|
77
|
+
|
|
78
|
+
// A configured spec path or URL expands into that spec's tag groups.
|
|
79
|
+
if (specs.some(spec => spec.id === value)) {
|
|
80
|
+
return tagGroups(
|
|
81
|
+
operations.filter(operation => operation.spec === value),
|
|
82
|
+
directory,
|
|
83
|
+
);
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
const
|
|
62
|
-
|
|
86
|
+
const matched = sortOperations(
|
|
87
|
+
operations.filter(operation => operationTags(operation).includes(value)),
|
|
88
|
+
);
|
|
63
89
|
if (!matched.length) {
|
|
64
|
-
throw new Error(`Navigation openapi entry "${
|
|
90
|
+
throw new Error(`Navigation openapi entry "${value}" matched no operations in the API spec.`);
|
|
65
91
|
}
|
|
66
92
|
return matched.map(operation => pageEntry(operation, directory));
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
/** Returns a config copy whose { openapi } entries are ordinary page objects. */
|
|
70
|
-
export function expandOpenApiNavigation(config, { operations, directory }) {
|
|
96
|
+
export function expandOpenApiNavigation(config, { operations, directory, specs }) {
|
|
71
97
|
function expandObject(value) {
|
|
72
98
|
if (Array.isArray(value)) return value.map(expandObject);
|
|
73
99
|
if (!value || typeof value !== 'object') return value;
|
|
@@ -78,7 +104,7 @@ export function expandOpenApiNavigation(config, { operations, directory }) {
|
|
|
78
104
|
const expanded = [];
|
|
79
105
|
for (const item of child) {
|
|
80
106
|
if (isOpenApiItem(item)) {
|
|
81
|
-
expanded.push(...expandItem(item, { operations, directory }));
|
|
107
|
+
expanded.push(...expandItem(item, { operations, directory, specs }));
|
|
82
108
|
} else {
|
|
83
109
|
expanded.push(expandObject(item));
|
|
84
110
|
}
|
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { createHighlighter, hastToHtml } from 'shiki';
|
|
10
|
-
import {
|
|
10
|
+
import { loadApiProject } from './lib/openapi-project.mjs';
|
|
11
11
|
|
|
12
12
|
// Keep in sync with DEFAULT_CODE_THEME in src/lib/code-blocks.ts (asserted by
|
|
13
13
|
// tests/openapi.test.mjs).
|
|
14
14
|
export const DEFAULT_OPENAPI_THEME = { light: 'github-light', dark: 'github-dark' };
|
|
15
15
|
|
|
16
16
|
const EMPTY_MODULE = `// Generated by @umami/shiso. Do not edit by hand.
|
|
17
|
-
import type { NormalizedOperation } from '@/lib/types';
|
|
17
|
+
import type { NormalizedOperation, SchemaPage } from '@/lib/types';
|
|
18
18
|
|
|
19
19
|
export const OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = {};
|
|
20
|
+
export const OPENAPI_SCHEMAS: Record<string, SchemaPage> = {};
|
|
20
21
|
`;
|
|
21
22
|
|
|
22
23
|
let highlighterPromise;
|
|
@@ -97,21 +98,45 @@ export async function generateOpenApiModule({
|
|
|
97
98
|
return { operations: 0 };
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
const operations = normalizeOperations(spec);
|
|
101
|
+
const project = await loadApiProject({ root, api: config.api });
|
|
102
102
|
const highlighter = await getHighlighter(theme);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const operations = {};
|
|
104
|
+
const schemas = {};
|
|
105
|
+
|
|
106
|
+
// Both the bare and the spec-qualified key resolve to the same object; bare
|
|
107
|
+
// keys that several specs share are omitted so pages must qualify them.
|
|
108
|
+
const highlighted = new Map();
|
|
109
|
+
for (const [key, operation] of project.operationsByKey) {
|
|
110
|
+
if (!operation) continue;
|
|
111
|
+
if (!highlighted.has(operation)) {
|
|
112
|
+
highlighted.set(operation, await highlightOperation(operation, highlighter, theme));
|
|
113
|
+
}
|
|
114
|
+
operations[key] = highlighted.get(operation);
|
|
115
|
+
}
|
|
116
|
+
for (const [key, schema] of project.schemasByKey) {
|
|
117
|
+
if (!schema) continue;
|
|
118
|
+
if (!highlighted.has(schema)) {
|
|
119
|
+
highlighted.set(
|
|
120
|
+
schema,
|
|
121
|
+
schema.example
|
|
122
|
+
? {
|
|
123
|
+
...schema,
|
|
124
|
+
exampleHtml: (await highlight(highlighter, schema.example, 'json', theme)).html,
|
|
125
|
+
}
|
|
126
|
+
: schema,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
schemas[key] = highlighted.get(schema);
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
const contents = `// Generated by @umami/shiso. Do not edit by hand.
|
|
110
|
-
import type { NormalizedOperation } from '@/lib/types';
|
|
133
|
+
import type { NormalizedOperation, SchemaPage } from '@/lib/types';
|
|
134
|
+
|
|
135
|
+
export const OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = ${JSON.stringify(operations, null, 2)};
|
|
111
136
|
|
|
112
|
-
export const
|
|
137
|
+
export const OPENAPI_SCHEMAS: Record<string, SchemaPage> = ${JSON.stringify(schemas, null, 2)};
|
|
113
138
|
`;
|
|
114
139
|
|
|
115
140
|
await writeIfChanged(output, contents);
|
|
116
|
-
return { operations: operations.length };
|
|
141
|
+
return { operations: project.operations.length, schemas: project.schemas.length };
|
|
117
142
|
}
|
|
@@ -22,12 +22,8 @@ import remarkMdx from 'remark-mdx';
|
|
|
22
22
|
import remarkParse from 'remark-parse';
|
|
23
23
|
import { unified } from 'unified';
|
|
24
24
|
import { headingText } from './lib/mdast.mjs';
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
normalizeOperationKey,
|
|
28
|
-
normalizeOperations,
|
|
29
|
-
operationSearchSections,
|
|
30
|
-
} from './lib/openapi.mjs';
|
|
25
|
+
import { operationSearchSections, schemaSearchSections } from './lib/openapi.mjs';
|
|
26
|
+
import { loadApiProject, lookupOperation, lookupSchema } from './lib/openapi-project.mjs';
|
|
31
27
|
import { createSlugger, slugifyId } from './lib/slug.mjs';
|
|
32
28
|
import { loadDocsConfig } from './load-docs-config.mjs';
|
|
33
29
|
import { loadShisoConfig } from './load-shiso-config.mjs';
|
|
@@ -148,10 +144,10 @@ function collectVisiblePages(container, pages = [], hidden = false) {
|
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
/** Frontmatter is YAML, but search only needs single lines from it. */
|
|
151
|
-
function
|
|
147
|
+
function frontmatterField(tree, name) {
|
|
152
148
|
const yaml = tree.children?.find(node => node.type === 'yaml');
|
|
153
|
-
const match = yaml?.value?.match(
|
|
154
|
-
return match ? match[1].trim() : undefined;
|
|
149
|
+
const match = yaml?.value?.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'));
|
|
150
|
+
return match ? match[1].trim().replace(/^["']|["']$/g, '') : undefined;
|
|
155
151
|
}
|
|
156
152
|
|
|
157
153
|
function frontmatterTitle(tree) {
|
|
@@ -225,13 +221,9 @@ export async function generateSearchIndex({
|
|
|
225
221
|
|
|
226
222
|
// Pages bound to an API operation get synthesized sections from the spec, so
|
|
227
223
|
// parameters and responses are searchable even though they render from data.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
operationsByKey = new Map(
|
|
232
|
-
normalizeOperations(spec).map(operation => [operation.key, operation]),
|
|
233
|
-
);
|
|
234
|
-
}
|
|
224
|
+
const project = docsJson.api?.spec
|
|
225
|
+
? await loadApiProject({ root, api: docsJson.api })
|
|
226
|
+
: undefined;
|
|
235
227
|
|
|
236
228
|
for (const scope of collectScopes(docsJson.navigation || {})) {
|
|
237
229
|
// Single-scope sites omit scope fields so their index stays unchanged.
|
|
@@ -272,13 +264,22 @@ export async function generateSearchIndex({
|
|
|
272
264
|
records.push({ url, page, heading, id, text, ...scopeFields });
|
|
273
265
|
}
|
|
274
266
|
|
|
275
|
-
const
|
|
276
|
-
|
|
267
|
+
const operation = project
|
|
268
|
+
? lookupOperation(project, frontmatterField(tree, 'openapi'))
|
|
269
|
+
: undefined;
|
|
277
270
|
if (operation) {
|
|
278
271
|
for (const { heading, id, text } of operationSearchSections(operation)) {
|
|
279
272
|
records.push({ url, page, heading, id, text, ...scopeFields });
|
|
280
273
|
}
|
|
281
274
|
}
|
|
275
|
+
const schema = project
|
|
276
|
+
? lookupSchema(project, frontmatterField(tree, 'openapi-schema'))
|
|
277
|
+
: undefined;
|
|
278
|
+
if (schema) {
|
|
279
|
+
for (const { heading, id, text } of schemaSearchSections(schema)) {
|
|
280
|
+
records.push({ url, page, heading, id, text, ...scopeFields });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
282
283
|
}
|
|
283
284
|
}
|
|
284
285
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads every OpenAPI spec a project configures and indexes the result.
|
|
3
|
+
*
|
|
4
|
+
* `api.spec` accepts one path or URL, or a list of them. Each spec becomes a
|
|
5
|
+
* set of operations (and webhooks) plus named schemas; with several specs the
|
|
6
|
+
* endpoint pages of each land in their own subdirectory and frontmatter keys
|
|
7
|
+
* may be qualified with the spec ("users.yaml GET /users") to disambiguate.
|
|
8
|
+
* Remote specs are fetched on load and cached under .shiso so a build still
|
|
9
|
+
* succeeds when the URL is unreachable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { parse as parseYaml } from 'yaml';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_API_DIRECTORY,
|
|
17
|
+
loadOpenApiSpec,
|
|
18
|
+
normalizeOperationKey,
|
|
19
|
+
normalizeOperations,
|
|
20
|
+
normalizeSchemaKey,
|
|
21
|
+
normalizeSchemas,
|
|
22
|
+
resolveApiDirectory,
|
|
23
|
+
} from './openapi.mjs';
|
|
24
|
+
import { slugify } from './slug.mjs';
|
|
25
|
+
|
|
26
|
+
const REMOTE = /^https?:\/\//i;
|
|
27
|
+
const remoteCache = new Map();
|
|
28
|
+
|
|
29
|
+
export function isRemoteSpec(source) {
|
|
30
|
+
return REMOTE.test(String(source || ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalizes api.spec into an ordered list of spec sources. */
|
|
34
|
+
export function apiSpecSources(api) {
|
|
35
|
+
const raw = api?.spec;
|
|
36
|
+
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
37
|
+
const sources = list.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean);
|
|
38
|
+
if (new Set(sources).size !== sources.length) {
|
|
39
|
+
throw new Error('api.spec lists the same spec more than once.');
|
|
40
|
+
}
|
|
41
|
+
return sources;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Folder name for one spec's endpoint pages on a multi-spec site. */
|
|
45
|
+
export function specDirectorySlug(source) {
|
|
46
|
+
const base = isRemoteSpec(source)
|
|
47
|
+
? new URL(source).pathname.split('/').filter(Boolean).pop() || new URL(source).hostname
|
|
48
|
+
: source.replace(/\\/g, '/').split('/').pop();
|
|
49
|
+
return slugify(base.replace(/\.(json|ya?ml)$/i, '').replace(/\./g, '-'), 'api');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateSpecDocument(spec, label) {
|
|
53
|
+
if (!spec || typeof spec !== 'object' || typeof spec.openapi !== 'string') {
|
|
54
|
+
throw new Error(`"${label}" is not an OpenAPI document: missing the "openapi" version field.`);
|
|
55
|
+
}
|
|
56
|
+
if (!spec.openapi.startsWith('3.')) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Unsupported OpenAPI version "${spec.openapi}" in "${label}": Shiso supports OpenAPI 3.0 and 3.1.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadRemoteSpec({ root, url, fetchImpl = globalThis.fetch }) {
|
|
64
|
+
const cacheDir = path.join(path.resolve(root), '.shiso', 'openapi-cache');
|
|
65
|
+
const cachePath = path.join(cacheDir, `${slugify(url, 'spec')}.txt`);
|
|
66
|
+
// One download per project and process: dev-server reloads reuse the copy.
|
|
67
|
+
const memoryKey = `${cacheDir}|${url}`;
|
|
68
|
+
|
|
69
|
+
if (remoteCache.has(memoryKey)) return { spec: remoteCache.get(memoryKey), specPath: cachePath };
|
|
70
|
+
|
|
71
|
+
let source;
|
|
72
|
+
let fetchError;
|
|
73
|
+
try {
|
|
74
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch is not available');
|
|
75
|
+
const response = await fetchImpl(url, { headers: { Accept: 'application/json, text/yaml' } });
|
|
76
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
77
|
+
source = await response.text();
|
|
78
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
79
|
+
await fs.writeFile(cachePath, source);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
fetchError = error;
|
|
82
|
+
source = await fs.readFile(cachePath, 'utf8').catch(() => undefined);
|
|
83
|
+
if (source === undefined) {
|
|
84
|
+
throw new Error(`Could not download OpenAPI spec "${url}": ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
console.warn(
|
|
87
|
+
`Could not download OpenAPI spec "${url}" (${error.message}); using the cached copy.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let spec;
|
|
92
|
+
try {
|
|
93
|
+
spec = parseYaml(source);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`Could not parse OpenAPI spec "${url}": ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
validateSpecDocument(spec, url);
|
|
98
|
+
if (!fetchError) remoteCache.set(memoryKey, spec);
|
|
99
|
+
return { spec, specPath: cachePath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Loads and indexes every configured spec. Returns the specs, all operations
|
|
104
|
+
* (each carrying `spec`, `directory`, and `pageRef`), all schemas, and lookup
|
|
105
|
+
* maps keyed by both the bare key ("GET /users") and the spec-qualified key
|
|
106
|
+
* ("users.yaml GET /users"). Bare keys shared by several specs are recorded
|
|
107
|
+
* in `ambiguous` and resolve to nothing, so pages must qualify them.
|
|
108
|
+
*/
|
|
109
|
+
export async function loadApiProject({ root, api, fetchImpl } = {}) {
|
|
110
|
+
const sources = apiSpecSources(api);
|
|
111
|
+
const directory = resolveApiDirectory(api);
|
|
112
|
+
const multi = sources.length > 1;
|
|
113
|
+
const specs = [];
|
|
114
|
+
const operations = [];
|
|
115
|
+
const schemas = [];
|
|
116
|
+
|
|
117
|
+
for (const source of sources) {
|
|
118
|
+
const loaded = isRemoteSpec(source)
|
|
119
|
+
? await loadRemoteSpec({ root, url: source, fetchImpl })
|
|
120
|
+
: await loadOpenApiSpec({ root, specPath: source });
|
|
121
|
+
const specDirectory = multi ? `${directory}/${specDirectorySlug(source)}` : directory;
|
|
122
|
+
const ownOperations = normalizeOperations(loaded.spec, { specId: source }).map(operation => ({
|
|
123
|
+
...operation,
|
|
124
|
+
directory: specDirectory,
|
|
125
|
+
pageRef: `${specDirectory}/${operation.id}`,
|
|
126
|
+
}));
|
|
127
|
+
specs.push({
|
|
128
|
+
id: source,
|
|
129
|
+
spec: loaded.spec,
|
|
130
|
+
specPath: loaded.specPath,
|
|
131
|
+
remote: isRemoteSpec(source),
|
|
132
|
+
directory: specDirectory,
|
|
133
|
+
title: loaded.spec.info?.title,
|
|
134
|
+
});
|
|
135
|
+
operations.push(...ownOperations);
|
|
136
|
+
schemas.push(...normalizeSchemas(loaded.spec, { specId: source }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const operationsByKey = new Map();
|
|
140
|
+
const schemasByKey = new Map();
|
|
141
|
+
const ambiguous = new Set();
|
|
142
|
+
const index = (map, key, value) => {
|
|
143
|
+
if (map.has(key)) {
|
|
144
|
+
ambiguous.add(key);
|
|
145
|
+
map.set(key, undefined);
|
|
146
|
+
} else if (!ambiguous.has(key)) {
|
|
147
|
+
map.set(key, value);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (const operation of operations) {
|
|
152
|
+
index(operationsByKey, operation.key, operation);
|
|
153
|
+
operationsByKey.set(`${operation.spec} ${operation.key}`, operation);
|
|
154
|
+
}
|
|
155
|
+
for (const schema of schemas) {
|
|
156
|
+
index(schemasByKey, schema.key, schema);
|
|
157
|
+
schemasByKey.set(`${schema.spec} ${schema.key}`, schema);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
specs,
|
|
162
|
+
directory,
|
|
163
|
+
multi,
|
|
164
|
+
operations,
|
|
165
|
+
schemas,
|
|
166
|
+
operationsByKey,
|
|
167
|
+
schemasByKey,
|
|
168
|
+
ambiguous,
|
|
169
|
+
specPaths: specs.filter(spec => !spec.remote).map(spec => spec.specPath),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolves an `openapi:` frontmatter value against a loaded project. */
|
|
174
|
+
export function lookupOperation(project, value) {
|
|
175
|
+
const key = normalizeOperationKey(value);
|
|
176
|
+
return key ? project.operationsByKey.get(key) : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolves an `openapi-schema:` frontmatter value against a loaded project. */
|
|
180
|
+
export function lookupSchema(project, value) {
|
|
181
|
+
const key = normalizeSchemaKey(value);
|
|
182
|
+
return key ? project.schemasByKey.get(key) : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** True when an `openapi:` value names an operation that several specs define. */
|
|
186
|
+
export function isAmbiguousOperation(project, value) {
|
|
187
|
+
const key = normalizeOperationKey(value);
|
|
188
|
+
return !!key && project.ambiguous.has(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** True when an `openapi-schema:` value names a schema that several specs define. */
|
|
192
|
+
export function isAmbiguousSchema(project, value) {
|
|
193
|
+
const key = normalizeSchemaKey(value);
|
|
194
|
+
return !!key && project.ambiguous.has(key);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { DEFAULT_API_DIRECTORY };
|