@umami/shiso 0.55.0 → 0.61.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 (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +835 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +73 -83
  7. package/scripts/check-package.mjs +18 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +252 -0
  11. package/scripts/load-docs-config.mjs +244 -0
  12. package/scripts/prerender.mjs +187 -0
  13. package/scripts/validate-config.mjs +104 -0
  14. package/scripts/vite-docs-config.mjs +60 -0
  15. package/src/App.tsx +39 -0
  16. package/src/components/Banner.tsx +69 -0
  17. package/src/components/CodeBlock.tsx +46 -0
  18. package/src/components/ConfiguredIcon.tsx +15 -0
  19. package/src/components/ContextualMenu.tsx +93 -0
  20. package/src/components/DocContent.tsx +100 -0
  21. package/src/components/Docs.tsx +126 -0
  22. package/src/components/Footer.tsx +84 -0
  23. package/src/components/Header.tsx +72 -0
  24. package/src/components/Layout.tsx +16 -0
  25. package/src/components/PageLinks.tsx +71 -0
  26. package/src/components/Search.tsx +208 -0
  27. package/src/components/SideNav.tsx +347 -0
  28. package/src/components/SocialIcon.tsx +88 -0
  29. package/src/components/ThemeToggle.tsx +34 -0
  30. package/src/components/TopNav.tsx +127 -0
  31. package/src/components/docs/Accordion.tsx +68 -0
  32. package/src/components/docs/Badge.tsx +171 -0
  33. package/src/components/docs/Callout.tsx +73 -0
  34. package/src/components/docs/Card.tsx +158 -0
  35. package/src/components/docs/CodeGroup.tsx +73 -0
  36. package/src/components/docs/Columns.tsx +20 -0
  37. package/src/components/docs/Expandable.tsx +28 -0
  38. package/src/components/docs/Frame.tsx +56 -0
  39. package/src/components/docs/Icon.tsx +30 -0
  40. package/src/components/docs/ParamField.tsx +45 -0
  41. package/src/components/docs/PropertiesTable.tsx +84 -0
  42. package/src/components/docs/ResponseField.tsx +36 -0
  43. package/src/components/docs/Steps.tsx +47 -0
  44. package/src/components/docs/Tabs.tsx +116 -0
  45. package/src/components/docs/Tooltip.tsx +21 -0
  46. package/src/components/docs/index.ts +15 -0
  47. package/src/components/docs/styles.ts +82 -0
  48. package/src/components/docs/utils.ts +118 -0
  49. package/src/components/icons/index.ts +17 -0
  50. package/src/components/ui/accordion.tsx +69 -0
  51. package/src/components/ui/alert.tsx +69 -0
  52. package/src/components/ui/badge.tsx +49 -0
  53. package/src/components/ui/button.tsx +58 -0
  54. package/src/components/ui/card.tsx +88 -0
  55. package/src/components/ui/collapsible.tsx +15 -0
  56. package/src/components/ui/command.tsx +173 -0
  57. package/src/components/ui/dialog.tsx +137 -0
  58. package/src/components/ui/dropdown-menu.tsx +257 -0
  59. package/src/components/ui/scroll-area.tsx +71 -0
  60. package/src/components/ui/sheet.tsx +124 -0
  61. package/src/components/ui/tabs.tsx +73 -0
  62. package/src/components/ui/tooltip.tsx +52 -0
  63. package/src/declarations.d.ts +9 -0
  64. package/src/entry-client.tsx +17 -0
  65. package/src/entry-server.tsx +77 -0
  66. package/src/generated/last-modified.ts +2 -0
  67. package/src/lib/content.ts +44 -0
  68. package/src/lib/docs-config.ts +682 -0
  69. package/src/lib/head.ts +220 -0
  70. package/src/lib/icon-registry.generated.ts +4 -0
  71. package/src/lib/icons.ts +29 -0
  72. package/src/lib/inline-markdown.tsx +86 -0
  73. package/src/lib/mdast.ts +56 -0
  74. package/src/lib/paths.ts +86 -0
  75. package/src/lib/remark-toc.ts +71 -0
  76. package/src/lib/search/config.ts +43 -0
  77. package/src/lib/search/provider.ts +80 -0
  78. package/src/lib/search/providers/local.ts +15 -0
  79. package/src/lib/search-index.generated.ts +4 -0
  80. package/src/lib/search.ts +100 -0
  81. package/src/lib/site-config.ts +104 -0
  82. package/src/lib/site-model.ts +221 -0
  83. package/src/lib/slug.ts +38 -0
  84. package/src/lib/types.ts +478 -0
  85. package/src/lib/utils.ts +6 -0
  86. package/src/pages/DocPage.tsx +33 -0
  87. package/src/styles/global.css +268 -0
  88. package/src/styles/tokens.css +114 -0
  89. package/types/client.d.ts +3 -0
  90. package/types/search.d.ts +17 -0
  91. package/vite.config.ts +342 -0
  92. package/LICENSE +0 -21
  93. package/dist/index.css +0 -189
  94. package/dist/index.d.ts +0 -57
  95. package/dist/index.js +0 -464
  96. package/dist/index.mjs +0 -437
  97. package/server/index.d.ts +0 -30
  98. package/server/index.js +0 -189
  99. package/styles.css +0 -4766
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Generates the project-local .shiso/search-index.generated.ts cache.
3
+ *
4
+ * Client-side search needs the plain text of every page, which only exists in
5
+ * the MDX sources at build time. Each navigable page is parsed to mdast and
6
+ * split into heading-bounded sections; headings share the slug algorithm with
7
+ * rehype-slug (src/lib/slug.ts), so result anchors always match rendered ids.
8
+ *
9
+ * Pages marked `hidden` in navigation are excluded, matching their exclusion
10
+ * from the sidebar and sitemap.
11
+ *
12
+ * Run via `pnpm search:index`, or automatically by the Vite plugin in
13
+ * vite.config.ts. The generated module is dynamically imported by the search
14
+ * dialog, so Vite splits it out of the initial bundle.
15
+ */
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+ import remarkFrontmatter from 'remark-frontmatter';
20
+ import remarkGfm from 'remark-gfm';
21
+ import remarkMdx from 'remark-mdx';
22
+ import remarkParse from 'remark-parse';
23
+ import { unified } from 'unified';
24
+ import { headingText } from '../src/lib/mdast.ts';
25
+ import { createSlugger } from '../src/lib/slug.ts';
26
+ import { loadDocsConfig } from './load-docs-config.mjs';
27
+
28
+ const DEFAULT_ROOT = process.cwd();
29
+
30
+ const parser = unified().use(remarkParse).use(remarkMdx).use(remarkFrontmatter).use(remarkGfm);
31
+
32
+ /** Mirrors normalizePageReference in src/lib/docs-config.ts. */
33
+ function normalizePageReference(pageRef) {
34
+ const value = pageRef
35
+ .trim()
36
+ .replace(/\\/g, '/')
37
+ .replace(/^\/+/, '')
38
+ .replace(/^docs\//, '')
39
+ .replace(/\.mdx?$/, '')
40
+ .replace(/\/+$/, '');
41
+
42
+ if (!value) {
43
+ return { fileSlug: 'index', slug: 'index' };
44
+ }
45
+
46
+ return {
47
+ fileSlug: value,
48
+ slug: value === 'index' ? 'index' : value.replace(/\/index$/, '') || 'index',
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Collects `{ fileSlug, slug }` for every non-hidden page in the navigation
54
+ * tree. A simplified mirror of the config walker: search only needs page refs
55
+ * and hidden inheritance, not labels or ordering.
56
+ */
57
+ function collectVisiblePages(container, pages = [], hidden = false) {
58
+ const items = [
59
+ ...(container.pages || []),
60
+ ...(container.groups || []),
61
+ ...(container.tabs || []),
62
+ ...(container.dropdowns || []),
63
+ ];
64
+
65
+ // Versions and languages collapse to their default entry, like the app does.
66
+ for (const key of ['versions', 'languages']) {
67
+ const entries = container[key];
68
+
69
+ if (Array.isArray(entries) && entries.length) {
70
+ items.push(entries.find(entry => entry.default) || entries[0]);
71
+ }
72
+ }
73
+
74
+ for (const item of items) {
75
+ if (typeof item === 'string') {
76
+ if (!hidden) {
77
+ pages.push(normalizePageReference(item));
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (!item || typeof item !== 'object') {
83
+ continue;
84
+ }
85
+
86
+ const itemHidden = hidden || item.hidden === true;
87
+
88
+ if (typeof item.page === 'string') {
89
+ if (!itemHidden) {
90
+ pages.push(normalizePageReference(item.page));
91
+ }
92
+ continue;
93
+ }
94
+
95
+ if (typeof item.root === 'string' && !itemHidden) {
96
+ pages.push(normalizePageReference(item.root));
97
+ }
98
+
99
+ collectVisiblePages(item, pages, itemHidden);
100
+ }
101
+
102
+ return pages;
103
+ }
104
+
105
+ /** Frontmatter is YAML, but search only needs the title line. */
106
+ function frontmatterTitle(tree) {
107
+ const yaml = tree.children?.find(node => node.type === 'yaml');
108
+ const match = yaml?.value?.match(/^title:\s*(.+)$/m);
109
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : undefined;
110
+ }
111
+
112
+ /** Node types whose children are separate blocks, so their text needs a space between. */
113
+ const BLOCK_TYPES = new Set([
114
+ 'root',
115
+ 'list',
116
+ 'listItem',
117
+ 'table',
118
+ 'tableRow',
119
+ 'tableCell',
120
+ 'blockquote',
121
+ 'mdxJsxFlowElement',
122
+ ]);
123
+
124
+ /** Like toText in src/lib/mdast.ts, but block children are space-separated. */
125
+ function blockText(node) {
126
+ if (typeof node.value === 'string') {
127
+ return node.value;
128
+ }
129
+
130
+ const separator = BLOCK_TYPES.has(node.type) ? ' ' : '';
131
+ return (node.children || []).map(blockText).join(separator);
132
+ }
133
+
134
+ /** Splits a document into heading-bounded sections of plain text. */
135
+ function collectSections(tree) {
136
+ const slugger = createSlugger();
137
+ const sections = [{ heading: undefined, id: undefined, parts: [] }];
138
+
139
+ for (const node of tree.children || []) {
140
+ if (node.type === 'yaml' || node.type === 'mdxjsEsm') {
141
+ continue;
142
+ }
143
+
144
+ if (node.type === 'heading') {
145
+ const heading = headingText(node);
146
+ sections.push({ heading, id: slugger.slug(heading), parts: [] });
147
+ continue;
148
+ }
149
+
150
+ const text = blockText(node).replace(/\s+/g, ' ').trim();
151
+
152
+ if (text) {
153
+ sections.at(-1).parts.push(text);
154
+ }
155
+ }
156
+
157
+ return sections
158
+ .map(({ heading, id, parts }) => ({ heading, id, text: parts.join(' ') }))
159
+ .filter(section => section.heading || section.text);
160
+ }
161
+
162
+ /** @param {{ config?: object, root?: string, output?: string }} [options] */
163
+ export async function generateSearchIndex({
164
+ config,
165
+ root = DEFAULT_ROOT,
166
+ output = path.join(root, '.shiso/search-index.generated.ts'),
167
+ } = {}) {
168
+ const docsJson = config ?? (await loadDocsConfig({ root })).config;
169
+ const docsPrefix = (docsJson.$shiso?.docsPrefix ?? '/docs').replace(/\/+$/, '');
170
+ const contentDir = (docsJson.$shiso?.contentDir ?? 'content/docs').replace(/^\/+|\/+$/g, '');
171
+
172
+ const seen = new Set();
173
+ const records = [];
174
+
175
+ for (const { fileSlug, slug } of collectVisiblePages(docsJson.navigation || {})) {
176
+ if (seen.has(fileSlug)) {
177
+ continue;
178
+ }
179
+
180
+ seen.add(fileSlug);
181
+
182
+ let source;
183
+ let filePath;
184
+
185
+ for (const extension of ['mdx', 'md']) {
186
+ filePath = path.join(root, contentDir, `${fileSlug}.${extension}`);
187
+ source = await fs.readFile(filePath, 'utf8').catch(() => undefined);
188
+
189
+ if (source !== undefined) {
190
+ break;
191
+ }
192
+ }
193
+
194
+ if (source === undefined) {
195
+ // Missing files fail config normalization; search just skips them.
196
+ continue;
197
+ }
198
+
199
+ const tree = parser.parse(source);
200
+ const url = slug === 'index' ? docsPrefix || '/' : `${docsPrefix}/${slug}`;
201
+ const page = frontmatterTitle(tree) || fileSlug;
202
+
203
+ for (const { heading, id, text } of collectSections(tree)) {
204
+ records.push({ url, page, heading, id, text });
205
+ }
206
+ }
207
+
208
+ // Emitted in the repo's formatter style (single quotes, bare keys, trailing
209
+ // commas) so the generated file passes `biome check` unchanged.
210
+ const quote = value => {
211
+ const escaped = value.replace(/\\/g, '\\\\');
212
+ const singles = (value.match(/'/g) || []).length;
213
+ const doubles = (value.match(/"/g) || []).length;
214
+
215
+ // Like the formatter: whichever quote needs fewer escapes, single on ties.
216
+ return singles > doubles
217
+ ? `"${escaped.replace(/"/g, '\\"')}"`
218
+ : `'${escaped.replace(/'/g, "\\'")}'`;
219
+ };
220
+ const body = records
221
+ .map(record => {
222
+ const fields = Object.entries(record)
223
+ .filter(([, value]) => value !== undefined)
224
+ .map(([key, value]) => ` ${key}: ${quote(value)},`)
225
+ .join('\n');
226
+
227
+ return ` {\n${fields}\n },`;
228
+ })
229
+ .join('\n');
230
+
231
+ const contents = `// Generated by scripts/generate-search-index.mjs. Do not edit by hand.
232
+ import type { SearchRecord } from '@/lib/search';
233
+
234
+ export const SEARCH_INDEX: SearchRecord[] = [
235
+ ${body}
236
+ ];
237
+ `;
238
+
239
+ const previous = await fs.readFile(output, 'utf8').catch(() => '');
240
+
241
+ if (previous !== contents) {
242
+ await fs.mkdir(path.dirname(output), { recursive: true });
243
+ await fs.writeFile(output, contents);
244
+ }
245
+
246
+ return { pages: seen.size, records: records.length, changed: previous !== contents };
247
+ }
248
+
249
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
250
+ const { pages, records } = await generateSearchIndex();
251
+ console.log(`Search index: ${records} sections across ${pages} pages`);
252
+ }
@@ -0,0 +1,244 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** Error raised while locating, reading, or parsing a Shiso configuration file. */
5
+ export class DocsConfigLoadError extends Error {
6
+ constructor(message, { cause, code, sourcePath }) {
7
+ super(message, { cause });
8
+ this.name = 'DocsConfigLoadError';
9
+ this.code = code;
10
+ this.sourcePath = sourcePath;
11
+ }
12
+ }
13
+
14
+ function isObject(value) {
15
+ return !!value && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function isInsideRoot(projectRoot, candidate) {
19
+ const relative = path.relative(projectRoot, candidate);
20
+ return (
21
+ relative === '' ||
22
+ (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..')
23
+ );
24
+ }
25
+
26
+ function parseLocation(source, error) {
27
+ const reported = error.message.match(/line\s+(\d+)(?:\s+column\s+(\d+))?/i);
28
+
29
+ if (reported) {
30
+ return ` at line ${reported[1]}${reported[2] ? `, column ${reported[2]}` : ''}`;
31
+ }
32
+
33
+ const position = error.message.match(/position\s+(\d+)/i)?.[1];
34
+ const unexpectedToken = error.message.match(/Unexpected token '([^']+)'/i)?.[1];
35
+ const parsedOffset = position === undefined ? -1 : Number(position);
36
+ const offset = parsedOffset >= 0 ? parsedOffset : source.indexOf(unexpectedToken || '');
37
+
38
+ if (offset < 0 || (!position && !unexpectedToken)) {
39
+ return '';
40
+ }
41
+
42
+ const before = source.slice(0, offset);
43
+ const line = before.split('\n').length;
44
+ const lastNewline = before.lastIndexOf('\n');
45
+ const column = offset - lastNewline;
46
+
47
+ return ` at line ${line}, column ${column}`;
48
+ }
49
+
50
+ /** Reads a JSON document while preserving its source path in every error. */
51
+ export async function loadJsonDocument(sourcePath, label = 'JSON document') {
52
+ const resolvedPath = path.resolve(sourcePath);
53
+ let source;
54
+
55
+ try {
56
+ source = await fs.readFile(resolvedPath, 'utf8');
57
+ } catch (error) {
58
+ const reason =
59
+ error.code === 'ENOENT' ? 'does not exist' : `could not be read: ${error.message}`;
60
+
61
+ throw new DocsConfigLoadError(`${label} "${resolvedPath}" ${reason}.`, {
62
+ cause: error,
63
+ code: error.code === 'ENOENT' ? 'NOT_FOUND' : 'READ_FAILED',
64
+ sourcePath: resolvedPath,
65
+ });
66
+ }
67
+
68
+ try {
69
+ return JSON.parse(source);
70
+ } catch (error) {
71
+ throw new DocsConfigLoadError(
72
+ `${label} "${resolvedPath}" contains invalid JSON${parseLocation(source, error)}: ${error.message}`,
73
+ {
74
+ cause: error,
75
+ code: 'INVALID_JSON',
76
+ sourcePath: resolvedPath,
77
+ },
78
+ );
79
+ }
80
+ }
81
+
82
+ function referenceError(message, { code, sourcePath }) {
83
+ return new DocsConfigLoadError(message, { code, sourcePath });
84
+ }
85
+
86
+ /** Resolves every `$ref` in one project configuration tree. */
87
+ async function resolveConfigReferences(entryPath, projectRoot) {
88
+ const lexicalRoot = path.resolve(projectRoot);
89
+ const canonicalRoot = await fs.realpath(projectRoot);
90
+ const sourcePaths = new Set();
91
+ const cache = new Map();
92
+
93
+ async function canonicalDocumentPath(sourcePath, referringPath) {
94
+ const resolvedPath = path.resolve(sourcePath);
95
+
96
+ if (!isInsideRoot(lexicalRoot, resolvedPath) && !isInsideRoot(canonicalRoot, resolvedPath)) {
97
+ throw referenceError(
98
+ `Config reference "${resolvedPath}" from "${referringPath}" leaves the project root "${canonicalRoot}".`,
99
+ { code: 'REF_OUTSIDE_ROOT', sourcePath: resolvedPath },
100
+ );
101
+ }
102
+
103
+ let canonicalPath;
104
+
105
+ try {
106
+ canonicalPath = await fs.realpath(resolvedPath);
107
+ } catch (error) {
108
+ const reason =
109
+ error.code === 'ENOENT' ? 'does not exist' : `could not be resolved: ${error.message}`;
110
+
111
+ throw new DocsConfigLoadError(`Referenced config "${resolvedPath}" ${reason}.`, {
112
+ cause: error,
113
+ code: error.code === 'ENOENT' ? 'NOT_FOUND' : 'READ_FAILED',
114
+ sourcePath: resolvedPath,
115
+ });
116
+ }
117
+
118
+ if (!isInsideRoot(canonicalRoot, canonicalPath)) {
119
+ throw referenceError(
120
+ `Config reference "${resolvedPath}" from "${referringPath}" resolves outside the project root.`,
121
+ { code: 'REF_OUTSIDE_ROOT', sourcePath: resolvedPath },
122
+ );
123
+ }
124
+
125
+ return canonicalPath;
126
+ }
127
+
128
+ async function resolveDocument(sourcePath, stack = []) {
129
+ const referringPath = stack.at(-1) || entryPath;
130
+ const canonicalPath = await canonicalDocumentPath(sourcePath, referringPath);
131
+
132
+ if (stack.includes(canonicalPath)) {
133
+ const cycle = [...stack.slice(stack.indexOf(canonicalPath)), canonicalPath]
134
+ .map(item => path.relative(projectRoot, item) || path.basename(item))
135
+ .join(' -> ');
136
+
137
+ throw referenceError(`Circular config reference detected: ${cycle}.`, {
138
+ code: 'CIRCULAR_REF',
139
+ sourcePath: canonicalPath,
140
+ });
141
+ }
142
+
143
+ if (cache.has(canonicalPath)) {
144
+ return cache.get(canonicalPath);
145
+ }
146
+
147
+ sourcePaths.add(canonicalPath);
148
+ const document = await loadJsonDocument(canonicalPath, 'Referenced config');
149
+ const resolved = await resolveValue(document, canonicalPath, [...stack, canonicalPath]);
150
+ cache.set(canonicalPath, resolved);
151
+ return resolved;
152
+ }
153
+
154
+ async function resolveReference(reference, sourcePath, stack) {
155
+ if (typeof reference !== 'string' || !reference.trim()) {
156
+ throw referenceError(`Config $ref in "${sourcePath}" must be a non-empty string.`, {
157
+ code: 'INVALID_REF',
158
+ sourcePath,
159
+ });
160
+ }
161
+
162
+ if (path.isAbsolute(reference) || /^[a-z][a-z0-9+.-]*:/i.test(reference)) {
163
+ throw referenceError(
164
+ `Config $ref "${reference}" in "${sourcePath}" must be a relative JSON file path.`,
165
+ { code: 'INVALID_REF', sourcePath },
166
+ );
167
+ }
168
+
169
+ if (path.extname(reference).toLowerCase() !== '.json') {
170
+ throw referenceError(`Config $ref "${reference}" in "${sourcePath}" must end in .json.`, {
171
+ code: 'INVALID_REF',
172
+ sourcePath,
173
+ });
174
+ }
175
+
176
+ return resolveDocument(path.resolve(path.dirname(sourcePath), reference), stack);
177
+ }
178
+
179
+ async function resolveValue(value, sourcePath, stack) {
180
+ if (Array.isArray(value)) {
181
+ return Promise.all(value.map(item => resolveValue(item, sourcePath, stack)));
182
+ }
183
+
184
+ if (!isObject(value)) {
185
+ return value;
186
+ }
187
+
188
+ if ('$ref' in value) {
189
+ const referenced = await resolveReference(value.$ref, sourcePath, stack);
190
+
191
+ // Object targets receive shallow sibling overrides. For arrays and
192
+ // primitives, the reference replaces the whole object and siblings are
193
+ // intentionally ignored.
194
+ if (!isObject(referenced)) {
195
+ return referenced;
196
+ }
197
+
198
+ const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== '$ref'));
199
+ const resolvedSiblings = await resolveValue(siblings, sourcePath, stack);
200
+ return { ...referenced, ...resolvedSiblings };
201
+ }
202
+
203
+ const entries = await Promise.all(
204
+ Object.entries(value).map(async ([key, item]) => [
205
+ key,
206
+ await resolveValue(item, sourcePath, stack),
207
+ ]),
208
+ );
209
+ return Object.fromEntries(entries);
210
+ }
211
+
212
+ const config = await resolveDocument(entryPath);
213
+ return { config, projectRoot: canonicalRoot, sourcePaths: [...sourcePaths] };
214
+ }
215
+
216
+ /**
217
+ * Loads the project docs configuration.
218
+ *
219
+ * Returning its resolved source path now gives the later `$ref` resolver a
220
+ * stable place from which to resolve relative references without changing
221
+ * every build-time consumer again.
222
+ */
223
+ export async function loadDocsConfig({ root = process.cwd(), configFile = 'docs.json' } = {}) {
224
+ const requestedRoot = path.resolve(root);
225
+ const requestedSourcePath = path.resolve(requestedRoot, configFile);
226
+ const { config, projectRoot, sourcePaths } = await resolveConfigReferences(
227
+ requestedSourcePath,
228
+ requestedRoot,
229
+ );
230
+ const sourcePath = sourcePaths[0] || requestedSourcePath;
231
+
232
+ return { config, projectRoot, sourcePath, sourcePaths };
233
+ }
234
+
235
+ export async function loadDocsSchema({
236
+ root = process.cwd(),
237
+ schemaFile = 'docs.schema.json',
238
+ } = {}) {
239
+ const projectRoot = path.resolve(root);
240
+ const sourcePath = path.resolve(projectRoot, schemaFile);
241
+ const schema = await loadJsonDocument(sourcePath, 'Docs schema');
242
+
243
+ return { projectRoot, schema, sourcePath };
244
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Prerenders every docs page to static HTML.
3
+ *
4
+ * Runs after `vite build` (client) and `vite build --ssr` (server):
5
+ * 1. Loads the SSR bundle from dist/server.
6
+ * 2. Renders each route from the normalized docs.json navigation.
7
+ * 3. Injects the rendered HTML and per-page head tags into the client
8
+ * dist/client/index.html template.
9
+ * 4. Writes dist/client/<base>/<route>/index.html plus a root entry and 404 page.
10
+ *
11
+ * Routes from the SSR bundle are base-relative; the deploy base is applied here
12
+ * so the output directory layout matches the URLs the router will produce.
13
+ */
14
+
15
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import process from 'node:process';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { loadDocsConfig } from './load-docs-config.mjs';
20
+
21
+ const DEFAULT_HEAD_OPEN = '<!--shiso-default-head-->';
22
+ const DEFAULT_HEAD_CLOSE = '<!--/shiso-default-head-->';
23
+
24
+ const root = process.cwd();
25
+ const clientDir = path.join(root, 'dist', 'client');
26
+ const template = await readFile(path.join(clientDir, 'index.html'), 'utf8');
27
+
28
+ if (!template.includes('<!--app-html-->')) {
29
+ throw new Error(
30
+ 'dist/client/index.html is missing the <!--app-html--> placeholder. ' +
31
+ 'Run "vite build" again before prerendering (prerender consumes the template in place).',
32
+ );
33
+ }
34
+
35
+ const { config: docsJson } = await loadDocsConfig({ root });
36
+ const docsPrefix = (docsJson.$shiso?.docsPrefix ?? '/docs').replace(/\/+$/, '');
37
+
38
+ const { render, getRoutes, getRedirects, getSitemapEntries, getMarkdownPages } = await import(
39
+ pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href
40
+ );
41
+
42
+ /** Vite's `base`, normalized to "" or "/prefix". */
43
+ function readBase() {
44
+ const match = template.match(/<script[^>]+src="([^"]*)\/assets\//);
45
+ const base = match?.[1] ?? '';
46
+ return base === '/' ? '' : base;
47
+ }
48
+
49
+ const base = readBase();
50
+
51
+ function withBase(routePath) {
52
+ return `${base}${routePath}`.replace(/\/{2,}/g, '/') || '/';
53
+ }
54
+
55
+ function fillTemplate(head, html) {
56
+ // Per-page head tags supersede the site-level defaults injected at build time.
57
+ const output = head
58
+ ? template.replace(new RegExp(`${DEFAULT_HEAD_OPEN}[\\s\\S]*?${DEFAULT_HEAD_CLOSE}`), '')
59
+ : template;
60
+
61
+ return output.replace('<!--app-head-->', head).replace('<!--app-html-->', html);
62
+ }
63
+
64
+ async function writePage(outputPath, contents) {
65
+ await mkdir(path.dirname(outputPath), { recursive: true });
66
+ await writeFile(outputPath, contents, 'utf8');
67
+ }
68
+
69
+ /** Maps a base-relative route to its output file, e.g. "/docs/a" -> "docs/a/index.html". */
70
+ function outputPathFor(routePath) {
71
+ const relative = withBase(routePath).replace(/^\//, '');
72
+ return path.join(clientDir, relative, 'index.html');
73
+ }
74
+
75
+ const routes = getRoutes();
76
+
77
+ for (const route of routes) {
78
+ const { html, head } = render(route);
79
+ await writePage(outputPathFor(route), fillTemplate(head, html));
80
+ }
81
+
82
+ // Root entry. When docs live at a prefix the root is a redirect; a meta refresh
83
+ // alone is slow and SEO-hostile, so pair it with a canonical link and an
84
+ // immediate history-replacing navigation.
85
+ if (docsPrefix) {
86
+ const target = withBase(`${docsPrefix}/`);
87
+
88
+ await writePage(
89
+ path.join(clientDir, 'index.html'),
90
+ fillTemplate(
91
+ [
92
+ `<link rel="canonical" href="${target}" />`,
93
+ `<meta http-equiv="refresh" content="0;url=${target}" />`,
94
+ `<script>location.replace(${JSON.stringify(target)});</script>`,
95
+ ].join('\n '),
96
+ '',
97
+ ),
98
+ );
99
+ }
100
+
101
+ // Raw markdown next to every page: "/docs/installation" -> "docs/installation.md".
102
+ // Served for the contextual menu's copy/view options and for AI tools.
103
+ const markdownPages = getMarkdownPages();
104
+
105
+ for (const { route, filePath } of markdownPages) {
106
+ const source = await readFile(path.join(root, ...filePath.split('/').filter(Boolean)), 'utf8');
107
+ const relative = withBase(route).replace(/^\//, '') || 'index';
108
+ await writePage(path.join(clientDir, `${relative}.md`), source);
109
+ }
110
+
111
+ // Redirect pages. Static hosting cannot serve real 301s, so each redirect
112
+ // gets the same canonical + meta refresh + immediate replace treatment as
113
+ // the root entry. Real pages always win over redirect rules.
114
+ const routeSet = new Set(routes.map(withBase));
115
+ const redirects = getRedirects();
116
+
117
+ function redirectTarget(destination) {
118
+ return /^[a-z][a-z0-9+.-]*:/i.test(destination) ? destination : withBase(destination);
119
+ }
120
+
121
+ for (const { source, destination } of redirects) {
122
+ if (routeSet.has(withBase(source))) {
123
+ console.warn(`Redirect source "${source}" is an existing page — skipped.`);
124
+ continue;
125
+ }
126
+
127
+ const target = redirectTarget(destination);
128
+
129
+ await writePage(
130
+ outputPathFor(source),
131
+ fillTemplate(
132
+ [
133
+ `<link rel="canonical" href="${target}" />`,
134
+ `<meta name="robots" content="noindex" />`,
135
+ `<meta http-equiv="refresh" content="0;url=${target}" />`,
136
+ `<script>location.replace(${JSON.stringify(target)});</script>`,
137
+ ].join('\n '),
138
+ '',
139
+ ),
140
+ );
141
+ }
142
+
143
+ // Sitemap, only when $shiso.siteUrl provides an absolute origin.
144
+ const sitemapEntries = getSitemapEntries();
145
+
146
+ if (sitemapEntries.length) {
147
+ const urls = sitemapEntries
148
+ .map(({ url, lastmod }) =>
149
+ [
150
+ ' <url>',
151
+ ` <loc>${url}</loc>`,
152
+ lastmod ? ` <lastmod>${lastmod}</lastmod>` : null,
153
+ ' </url>',
154
+ ]
155
+ .filter(Boolean)
156
+ .join('\n'),
157
+ )
158
+ .join('\n');
159
+
160
+ await writePage(
161
+ path.join(clientDir, 'sitemap.xml'),
162
+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`,
163
+ );
164
+ }
165
+
166
+ // 404 fallback renders the app shell so client routing can take over
167
+ // on hosts that serve 404.html for unknown paths (e.g. GitHub Pages).
168
+ const notFound = render('/404');
169
+ await writePage(path.join(clientDir, '404.html'), fillTemplate(notFound.head, notFound.html));
170
+
171
+ // Guard against a base/route mismatch silently producing unreachable files.
172
+ const stray = routes.filter(route => !withBase(route).startsWith(base || '/'));
173
+
174
+ if (stray.length) {
175
+ throw new Error(`Routes fall outside the deploy base "${base}": ${stray.join(', ')}`);
176
+ }
177
+
178
+ const extras = [
179
+ redirects.length ? `${redirects.length} redirects` : null,
180
+ sitemapEntries.length ? 'sitemap.xml' : null,
181
+ ]
182
+ .filter(Boolean)
183
+ .join(', ');
184
+
185
+ console.log(
186
+ `Prerendered ${routes.length} pages to ${path.relative(root, clientDir)}${extras ? ` (+ ${extras})` : ''}`,
187
+ );