@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.
Files changed (44) hide show
  1. package/dist/chunks/App.js +1081 -47
  2. package/dist/chunks/architectureDiagram-5GKGNRK7.js +1 -1
  3. package/dist/chunks/chunk-GMAD6QVW.js +1 -1
  4. package/dist/chunks/cose-bilkent-JH36ORCC.js +1 -1
  5. package/dist/chunks/dist.js +1 -1
  6. package/dist/chunks/docs.js +183 -8
  7. package/dist/chunks/ganttDiagram-EL5Y4UJY.js +1 -1
  8. package/dist/chunks/src.js +1 -1
  9. package/dist/components.js +1 -1
  10. package/dist/entry-client.js +1 -1
  11. package/dist/entry-server.js +1 -1
  12. package/docs.schema.json +1164 -1135
  13. package/package.json +1 -2
  14. package/scripts/check-content.mjs +42 -15
  15. package/scripts/expand-openapi-navigation.mjs +47 -21
  16. package/scripts/generate-openapi.mjs +36 -11
  17. package/scripts/generate-search-index.mjs +19 -18
  18. package/scripts/lib/openapi-project.mjs +197 -0
  19. package/scripts/lib/openapi.mjs +257 -111
  20. package/scripts/lib/request-samples.mjs +323 -0
  21. package/scripts/load-docs-config.mjs +23 -15
  22. package/scripts/prerender.mjs +17 -14
  23. package/scripts/vite-docs-config.mjs +1 -0
  24. package/src/components/ApiPlayground.tsx +522 -0
  25. package/src/components/DocContent.tsx +15 -4
  26. package/src/components/Docs.tsx +18 -3
  27. package/src/components/LanguageSwitcher.tsx +26 -30
  28. package/src/components/OpenApiOperation.tsx +58 -12
  29. package/src/components/OpenApiSchema.tsx +97 -0
  30. package/src/components/SideNav.tsx +2 -2
  31. package/src/lib/openapi.generated.ts +2 -1
  32. package/src/lib/openapi.ts +104 -10
  33. package/src/lib/site-model.ts +12 -0
  34. package/src/lib/translations/de.json +26 -1
  35. package/src/lib/translations/en.json +26 -1
  36. package/src/lib/translations/es.json +26 -1
  37. package/src/lib/translations/fr.json +26 -1
  38. package/src/lib/translations/ja.json +26 -1
  39. package/src/lib/translations/zh-Hans.json +26 -1
  40. package/src/lib/translations/zh-Hant.json +26 -1
  41. package/src/lib/types.ts +89 -5
  42. package/types/labels.d.ts +25 -0
  43. package/vite.config.ts +3 -4
  44. package/CHANGELOG.md +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.18.0",
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
- loadOpenApiSpec,
18
- normalizeOperationKey,
19
- normalizeOperations,
20
- operationAnchors,
21
- } from './lib/openapi.mjs';
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.match(/^openapi:\s*(.+)$/m)?.[1]?.trim(),
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
- let openApiByKey;
260
- if (docsConfig.api?.spec) {
261
- const { spec } = await loadOpenApiSpec({ root: projectRoot, specPath: docsConfig.api.spec });
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 operationKey = normalizeOperationKey(document.openapi);
270
- const operation = operationKey ? openApiByKey?.get(operationKey) : undefined;
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
- for (const anchor of operationAnchors(operation)) {
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: operation.summary || `${operation.method} ${operation.path}`,
33
- method: operation.method,
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
- const tags = [];
48
- for (const operation of operations) {
49
- for (const tag of operation.tags) {
50
- if (!tags.includes(tag)) tags.push(tag);
51
- }
52
- }
53
- return tags.map(tag => ({
54
- group: tag,
55
- pages: sortOperations(operations.filter(operation => operation.tags.includes(tag))).map(
56
- operation => pageEntry(operation, directory),
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 tag = item.openapi.trim();
62
- const matched = sortOperations(operations.filter(operation => operation.tags.includes(tag)));
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 "${tag}" matched no operations in the API spec.`);
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 { loadOpenApiSpec, normalizeOperations } from './lib/openapi.mjs';
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 { spec } = await loadOpenApiSpec({ root, specPath: config.api.spec });
101
- const operations = normalizeOperations(spec);
101
+ const project = await loadApiProject({ root, api: config.api });
102
102
  const highlighter = await getHighlighter(theme);
103
- const record = {};
104
-
105
- for (const operation of operations) {
106
- record[operation.key] = await highlightOperation(operation, highlighter, theme);
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 OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = ${JSON.stringify(record, null, 2)};
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
- loadOpenApiSpec,
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 frontmatterOpenApi(tree) {
147
+ function frontmatterField(tree, name) {
152
148
  const yaml = tree.children?.find(node => node.type === 'yaml');
153
- const match = yaml?.value?.match(/^openapi:\s*(.+)$/m);
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
- let operationsByKey;
229
- if (docsJson.api?.spec) {
230
- const { spec } = await loadOpenApiSpec({ root, specPath: docsJson.api.spec });
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 operationKey = normalizeOperationKey(frontmatterOpenApi(tree));
276
- const operation = operationKey ? operationsByKey?.get(operationKey) : undefined;
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 };