@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,208 @@
1
+ /** Expands { glob } navigation entries into ordinary { page } entries. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']);
7
+ const GLOB_KEYS = new Set(['glob', 'exclude']);
8
+
9
+ async function listContentFiles(directory) {
10
+ const files = [];
11
+ let entries;
12
+
13
+ try {
14
+ entries = await fs.readdir(directory, { withFileTypes: true });
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return files;
17
+ throw error;
18
+ }
19
+
20
+ for (const entry of entries) {
21
+ const item = path.join(directory, entry.name);
22
+ if (entry.isDirectory()) {
23
+ files.push(...(await listContentFiles(item)));
24
+ } else if (entry.isFile() && CONTENT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
25
+ files.push(item);
26
+ }
27
+ }
28
+
29
+ return files;
30
+ }
31
+
32
+ function globPattern(value) {
33
+ const pattern = String(value || '')
34
+ .trim()
35
+ .replace(/\\/g, '/')
36
+ .replace(/^\.\//, '')
37
+ .replace(/\/+$/, '');
38
+
39
+ if (!pattern || pattern.startsWith('/') || pattern.split('/').includes('..')) {
40
+ throw new Error(`Invalid navigation glob "${value}": use a relative path inside contentDir.`);
41
+ }
42
+
43
+ return pattern;
44
+ }
45
+
46
+ function globRegex(pattern) {
47
+ let source = '^';
48
+
49
+ for (let index = 0; index < pattern.length; index += 1) {
50
+ const character = pattern[index];
51
+
52
+ if (character === '*' && pattern[index + 1] === '*') {
53
+ if (pattern[index + 2] === '/') {
54
+ source += '(?:.*/)?';
55
+ index += 2;
56
+ } else {
57
+ source += '.*';
58
+ index += 1;
59
+ }
60
+ } else if (character === '*') {
61
+ source += '[^/]*';
62
+ } else if (character === '?') {
63
+ source += '[^/]';
64
+ } else {
65
+ source += character.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
66
+ }
67
+ }
68
+
69
+ return new RegExp(`${source}$`);
70
+ }
71
+
72
+ function navigationFrontmatter(source, sourcePath) {
73
+ const block = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || '';
74
+ if (!block) return {};
75
+
76
+ try {
77
+ const values = parseYaml(block);
78
+ return values && typeof values === 'object' && !Array.isArray(values) ? values : {};
79
+ } catch (error) {
80
+ throw new Error(`Could not parse frontmatter in "${sourcePath}": ${error.message}`);
81
+ }
82
+ }
83
+
84
+ function isGlobItem(value) {
85
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'glob' in value;
86
+ }
87
+
88
+ function assertGlobItem(item) {
89
+ if (typeof item.glob !== 'string' || !item.glob.trim()) {
90
+ throw new Error('Invalid navigation glob: "glob" must be a non-empty string.');
91
+ }
92
+ const unknown = Object.keys(item).filter(key => !GLOB_KEYS.has(key));
93
+ if (unknown.length) {
94
+ throw new Error(
95
+ `Invalid navigation glob "${item.glob}": unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.map(key => `"${key}"`).join(', ')}.`,
96
+ );
97
+ }
98
+ if (item.exclude !== undefined && !Array.isArray(item.exclude)) {
99
+ throw new Error(`Invalid navigation glob "${item.glob}": "exclude" must be an array.`);
100
+ }
101
+ if (item.exclude?.some(pattern => typeof pattern !== 'string' || !pattern.trim())) {
102
+ throw new Error(
103
+ `Invalid navigation glob "${item.glob}": every "exclude" entry must be a non-empty string.`,
104
+ );
105
+ }
106
+ }
107
+
108
+ function pageCandidate(contentRoot, filePath) {
109
+ const relativeFile = path.relative(contentRoot, filePath).replace(/\\/g, '/');
110
+ const fileSlug = relativeFile.replace(/\.mdx?$/i, '');
111
+ return { filePath, relativeFile, fileSlug };
112
+ }
113
+
114
+ function matches(candidate, pattern) {
115
+ const target = /\.mdx?$/i.test(pattern) ? candidate.relativeFile : candidate.fileSlug;
116
+ return globRegex(pattern).test(target);
117
+ }
118
+
119
+ async function expandGlob(item, candidates, projectRoot) {
120
+ assertGlobItem(item);
121
+ const pattern = globPattern(item.glob);
122
+ const exclusions = (item.exclude || []).map(globPattern);
123
+ const matched = candidates.filter(
124
+ candidate =>
125
+ matches(candidate, pattern) && !exclusions.some(exclusion => matches(candidate, exclusion)),
126
+ );
127
+
128
+ if (!matched.length) {
129
+ throw new Error(`Navigation glob "${pattern}" matched no Markdown or MDX files.`);
130
+ }
131
+
132
+ const entries = await Promise.all(
133
+ matched.map(async candidate => {
134
+ const source = await fs.readFile(candidate.filePath, 'utf8');
135
+ const sourcePath = path.relative(projectRoot, candidate.filePath).replace(/\\/g, '/');
136
+ const frontmatter = navigationFrontmatter(source, sourcePath);
137
+ const title =
138
+ typeof frontmatter.sidebarTitle === 'string' && frontmatter.sidebarTitle
139
+ ? frontmatter.sidebarTitle
140
+ : typeof frontmatter.title === 'string' && frontmatter.title
141
+ ? frontmatter.title
142
+ : undefined;
143
+
144
+ return {
145
+ page: candidate.fileSlug,
146
+ ...(title ? { title } : {}),
147
+ ...(frontmatter.hidden === true ? { hidden: true } : {}),
148
+ order: typeof frontmatter.order === 'number' ? frontmatter.order : Number.POSITIVE_INFINITY,
149
+ };
150
+ }),
151
+ );
152
+
153
+ entries.sort((left, right) => left.order - right.order || left.page.localeCompare(right.page));
154
+ return entries.map(({ order: _order, ...entry }) => entry);
155
+ }
156
+
157
+ /** True when a navigation tree contains at least one { glob } page entry. */
158
+ export function hasNavigationGlobs(navigation) {
159
+ if (Array.isArray(navigation)) return navigation.some(hasNavigationGlobs);
160
+ if (!navigation || typeof navigation !== 'object') return false;
161
+ if (isGlobItem(navigation)) return true;
162
+ return Object.values(navigation).some(hasNavigationGlobs);
163
+ }
164
+
165
+ /** Returns a config copy whose navigation globs are ordinary page objects. */
166
+ export async function expandNavigationGlobs(config, { root, contentDir }) {
167
+ if (!hasNavigationGlobs(config.navigation)) return config;
168
+
169
+ const projectRoot = path.resolve(root);
170
+ const contentRoot = path.resolve(projectRoot, contentDir);
171
+ const files = await listContentFiles(contentRoot);
172
+ const candidates = files.map(filePath => pageCandidate(contentRoot, filePath));
173
+ const duplicateSlugs = candidates.filter(
174
+ (candidate, index) =>
175
+ candidates.findIndex(item => item.fileSlug === candidate.fileSlug) !== index,
176
+ );
177
+
178
+ if (duplicateSlugs.length) {
179
+ throw new Error(
180
+ `Navigation globs found both .md and .mdx for "${duplicateSlugs[0].fileSlug}" in ${contentDir}.`,
181
+ );
182
+ }
183
+
184
+ async function expandObject(value) {
185
+ if (Array.isArray(value)) return Promise.all(value.map(expandObject));
186
+ if (!value || typeof value !== 'object') return value;
187
+
188
+ const entries = await Promise.all(
189
+ Object.entries(value).map(async ([key, child]) => {
190
+ if (key === 'pages' && Array.isArray(child)) {
191
+ const expanded = [];
192
+ for (const item of child) {
193
+ if (isGlobItem(item)) {
194
+ expanded.push(...(await expandGlob(item, candidates, projectRoot)));
195
+ } else {
196
+ expanded.push(await expandObject(item));
197
+ }
198
+ }
199
+ return [key, expanded];
200
+ }
201
+ return [key, await expandObject(child)];
202
+ }),
203
+ );
204
+ return Object.fromEntries(entries);
205
+ }
206
+
207
+ return { ...config, navigation: await expandObject(config.navigation) };
208
+ }
@@ -0,0 +1,94 @@
1
+ /** Expands { openapi } navigation entries into ordinary { page } entries. */
2
+
3
+ const OPENAPI_KEYS = new Set(['openapi']);
4
+
5
+ function isOpenApiItem(value) {
6
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'openapi' in value;
7
+ }
8
+
9
+ function assertOpenApiItem(item) {
10
+ const unknown = Object.keys(item).filter(key => !OPENAPI_KEYS.has(key));
11
+ if (unknown.length) {
12
+ throw new Error(
13
+ `Invalid navigation openapi entry: unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.map(key => `"${key}"`).join(', ')}.`,
14
+ );
15
+ }
16
+ if (item.openapi !== true && (typeof item.openapi !== 'string' || !item.openapi.trim())) {
17
+ throw new Error('Invalid navigation openapi entry: use true or a non-empty tag name.');
18
+ }
19
+ }
20
+
21
+ /** True when a navigation tree contains at least one { openapi } page entry. */
22
+ export function hasOpenApiItems(navigation) {
23
+ if (Array.isArray(navigation)) return navigation.some(hasOpenApiItems);
24
+ if (!navigation || typeof navigation !== 'object') return false;
25
+ if (isOpenApiItem(navigation)) return true;
26
+ return Object.values(navigation).some(hasOpenApiItems);
27
+ }
28
+
29
+ function pageEntry(operation, directory) {
30
+ return {
31
+ page: `${directory}/${operation.id}`,
32
+ title: operation.summary || `${operation.method} ${operation.path}`,
33
+ method: operation.method,
34
+ };
35
+ }
36
+
37
+ function sortOperations(operations) {
38
+ return [...operations].sort(
39
+ (left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method),
40
+ );
41
+ }
42
+
43
+ function expandItem(item, { operations, directory }) {
44
+ assertOpenApiItem(item);
45
+
46
+ 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
+ }));
59
+ }
60
+
61
+ const tag = item.openapi.trim();
62
+ const matched = sortOperations(operations.filter(operation => operation.tags.includes(tag)));
63
+ if (!matched.length) {
64
+ throw new Error(`Navigation openapi entry "${tag}" matched no operations in the API spec.`);
65
+ }
66
+ return matched.map(operation => pageEntry(operation, directory));
67
+ }
68
+
69
+ /** Returns a config copy whose { openapi } entries are ordinary page objects. */
70
+ export function expandOpenApiNavigation(config, { operations, directory }) {
71
+ function expandObject(value) {
72
+ if (Array.isArray(value)) return value.map(expandObject);
73
+ if (!value || typeof value !== 'object') return value;
74
+
75
+ return Object.fromEntries(
76
+ Object.entries(value).map(([key, child]) => {
77
+ if (key === 'pages' && Array.isArray(child)) {
78
+ const expanded = [];
79
+ for (const item of child) {
80
+ if (isOpenApiItem(item)) {
81
+ expanded.push(...expandItem(item, { operations, directory }));
82
+ } else {
83
+ expanded.push(expandObject(item));
84
+ }
85
+ }
86
+ return [key, expanded];
87
+ }
88
+ return [key, expandObject(child)];
89
+ }),
90
+ );
91
+ }
92
+
93
+ return { ...config, navigation: expandObject(config.navigation) };
94
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Emits .shiso/openapi.generated.ts: every normalized operation from the
3
+ * project's OpenAPI spec, with code samples and response examples highlighted
4
+ * by Shiki at build time so no highlighter ships to the browser.
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { createHighlighter, hastToHtml } from 'shiki';
10
+ import { loadOpenApiSpec, normalizeOperations } from './lib/openapi.mjs';
11
+
12
+ // Keep in sync with DEFAULT_CODE_THEME in src/lib/code-blocks.ts (asserted by
13
+ // tests/openapi.test.mjs).
14
+ export const DEFAULT_OPENAPI_THEME = { light: 'github-light', dark: 'github-dark' };
15
+
16
+ const EMPTY_MODULE = `// Generated by @umami/shiso. Do not edit by hand.
17
+ import type { NormalizedOperation } from '@/lib/types';
18
+
19
+ export const OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = {};
20
+ `;
21
+
22
+ let highlighterPromise;
23
+
24
+ async function getHighlighter(theme) {
25
+ if (!highlighterPromise) {
26
+ highlighterPromise = createHighlighter({ themes: [theme.light, theme.dark], langs: [] });
27
+ }
28
+ return highlighterPromise;
29
+ }
30
+
31
+ async function highlight(highlighter, source, lang, theme) {
32
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
33
+ await highlighter.loadLanguage(lang);
34
+ }
35
+
36
+ const hast = highlighter.codeToHast(source, {
37
+ lang,
38
+ themes: theme,
39
+ defaultColor: false,
40
+ });
41
+ const code = hast.children[0]?.children?.[0];
42
+ const children = code?.children || [];
43
+ const lineCount = children.filter(node => node.tagName === 'span').length;
44
+
45
+ return { html: hastToHtml({ type: 'root', children }), lineCount };
46
+ }
47
+
48
+ async function highlightOperation(operation, highlighter, theme) {
49
+ const samples = [];
50
+ for (const sample of operation.samples) {
51
+ samples.push({
52
+ ...sample,
53
+ ...(await highlight(highlighter, sample.source, sample.language, theme)),
54
+ });
55
+ }
56
+
57
+ const responses = [];
58
+ for (const response of operation.responses) {
59
+ responses.push(
60
+ response.example
61
+ ? {
62
+ ...response,
63
+ exampleHtml: (await highlight(highlighter, response.example, 'json', theme)).html,
64
+ }
65
+ : response,
66
+ );
67
+ }
68
+
69
+ const requestBody =
70
+ operation.requestBody?.example !== undefined
71
+ ? {
72
+ ...operation.requestBody,
73
+ exampleHtml: (await highlight(highlighter, operation.requestBody.example, 'json', theme))
74
+ .html,
75
+ }
76
+ : operation.requestBody;
77
+
78
+ return { ...operation, samples, responses, requestBody };
79
+ }
80
+
81
+ async function writeIfChanged(output, contents) {
82
+ const existing = await fs.readFile(output, 'utf8').catch(() => undefined);
83
+ if (existing === contents) return;
84
+ await fs.mkdir(path.dirname(output), { recursive: true });
85
+ await fs.writeFile(output, contents);
86
+ }
87
+
88
+ /** Generates the openapi data module for the project, or an empty fallback. */
89
+ export async function generateOpenApiModule({
90
+ root,
91
+ config,
92
+ theme = DEFAULT_OPENAPI_THEME,
93
+ output,
94
+ }) {
95
+ if (!config?.api?.spec) {
96
+ await writeIfChanged(output, EMPTY_MODULE);
97
+ return { operations: 0 };
98
+ }
99
+
100
+ const { spec } = await loadOpenApiSpec({ root, specPath: config.api.spec });
101
+ const operations = normalizeOperations(spec);
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);
107
+ }
108
+
109
+ const contents = `// Generated by @umami/shiso. Do not edit by hand.
110
+ import type { NormalizedOperation } from '@/lib/types';
111
+
112
+ export const OPENAPI_OPERATIONS: Record<string, NormalizedOperation> = ${JSON.stringify(record, null, 2)};
113
+ `;
114
+
115
+ await writeIfChanged(output, contents);
116
+ return { operations: operations.length };
117
+ }
@@ -22,6 +22,12 @@ 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
31
  import { createSlugger, slugifyId } from './lib/slug.mjs';
26
32
  import { loadDocsConfig } from './load-docs-config.mjs';
27
33
  import { loadShisoConfig } from './load-shiso-config.mjs';
@@ -141,7 +147,13 @@ function collectVisiblePages(container, pages = [], hidden = false) {
141
147
  return pages;
142
148
  }
143
149
 
144
- /** Frontmatter is YAML, but search only needs the title line. */
150
+ /** Frontmatter is YAML, but search only needs single lines from it. */
151
+ function frontmatterOpenApi(tree) {
152
+ 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;
155
+ }
156
+
145
157
  function frontmatterTitle(tree) {
146
158
  const yaml = tree.children?.find(node => node.type === 'yaml');
147
159
  const match = yaml?.value?.match(/^title:\s*(.+)$/m);
@@ -211,6 +223,16 @@ export async function generateSearchIndex({
211
223
  const seen = new Set();
212
224
  const records = [];
213
225
 
226
+ // Pages bound to an API operation get synthesized sections from the spec, so
227
+ // 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
+ }
235
+
214
236
  for (const scope of collectScopes(docsJson.navigation || {})) {
215
237
  // Single-scope sites omit scope fields so their index stays unchanged.
216
238
  const scopeFields =
@@ -249,6 +271,14 @@ export async function generateSearchIndex({
249
271
  for (const { heading, id, text } of collectSections(tree)) {
250
272
  records.push({ url, page, heading, id, text, ...scopeFields });
251
273
  }
274
+
275
+ const operationKey = normalizeOperationKey(frontmatterOpenApi(tree));
276
+ const operation = operationKey ? operationsByKey?.get(operationKey) : undefined;
277
+ if (operation) {
278
+ for (const { heading, id, text } of operationSearchSections(operation)) {
279
+ records.push({ url, page, heading, id, text, ...scopeFields });
280
+ }
281
+ }
252
282
  }
253
283
  }
254
284