@umami/shiso 0.55.0 → 1.0.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/CHANGELOG.md +39 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +896 -0
- package/mdx.config.ts +143 -0
- package/package.json +74 -83
- package/scripts/check-package.mjs +54 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +298 -0
- package/scripts/lib/mdast.mjs +23 -0
- package/scripts/lib/slug.mjs +21 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +194 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +38 -0
- package/src/components/Banner.tsx +69 -0
- package/src/components/CodeBlock.tsx +46 -0
- package/src/components/ConfiguredIcon.tsx +15 -0
- package/src/components/ContextualMenu.tsx +93 -0
- package/src/components/DocContent.tsx +105 -0
- package/src/components/Docs.tsx +134 -0
- package/src/components/Footer.tsx +81 -0
- package/src/components/Header.tsx +82 -0
- package/src/components/LanguageSwitcher.tsx +59 -0
- package/src/components/Layout.tsx +24 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +217 -0
- package/src/components/SideNav.tsx +347 -0
- package/src/components/SocialIcon.tsx +88 -0
- package/src/components/ThemeToggle.tsx +34 -0
- package/src/components/TopNav.tsx +127 -0
- package/src/components/VersionSwitcher.tsx +60 -0
- package/src/components/docs/Accordion.tsx +68 -0
- package/src/components/docs/Badge.tsx +171 -0
- package/src/components/docs/Callout.tsx +73 -0
- package/src/components/docs/Card.tsx +158 -0
- package/src/components/docs/CodeGroup.tsx +73 -0
- package/src/components/docs/Columns.tsx +20 -0
- package/src/components/docs/Expandable.tsx +28 -0
- package/src/components/docs/Frame.tsx +56 -0
- package/src/components/docs/Icon.tsx +30 -0
- package/src/components/docs/ParamField.tsx +45 -0
- package/src/components/docs/PropertiesTable.tsx +84 -0
- package/src/components/docs/ResponseField.tsx +36 -0
- package/src/components/docs/Steps.tsx +47 -0
- package/src/components/docs/Tabs.tsx +116 -0
- package/src/components/docs/Tooltip.tsx +21 -0
- package/src/components/docs/index.ts +15 -0
- package/src/components/docs/styles.ts +82 -0
- package/src/components/docs/utils.ts +118 -0
- package/src/components/icons/index.ts +17 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert.tsx +69 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +88 -0
- package/src/components/ui/collapsible.tsx +15 -0
- package/src/components/ui/command.tsx +173 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/scroll-area.tsx +71 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/tabs.tsx +73 -0
- package/src/components/ui/tooltip.tsx +52 -0
- package/src/declarations.d.ts +9 -0
- package/src/entry-client.tsx +17 -0
- package/src/entry-server.tsx +89 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +986 -0
- package/src/lib/head.ts +231 -0
- package/src/lib/icon-registry.generated.ts +4 -0
- package/src/lib/icons.ts +29 -0
- package/src/lib/inline-markdown.tsx +86 -0
- package/src/lib/locale.ts +39 -0
- package/src/lib/mdast.ts +56 -0
- package/src/lib/paths.ts +86 -0
- package/src/lib/remark-toc.ts +71 -0
- package/src/lib/search/config.ts +43 -0
- package/src/lib/search/provider.ts +85 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +128 -0
- package/src/lib/site-config.ts +117 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +515 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/DocPage.tsx +33 -0
- package/src/styles/global.css +268 -0
- package/src/styles/tokens.css +114 -0
- package/types/client.d.ts +3 -0
- package/types/search.d.ts +27 -0
- package/vite.config.ts +342 -0
- package/LICENSE +0 -21
- package/dist/index.css +0 -189
- package/dist/index.d.ts +0 -57
- package/dist/index.js +0 -464
- package/dist/index.mjs +0 -437
- package/server/index.d.ts +0 -30
- package/server/index.js +0 -189
- package/styles.css +0 -4766
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
|
|
20
|
+
const DEFAULT_HEAD_OPEN = '<!--shiso-default-head-->';
|
|
21
|
+
const DEFAULT_HEAD_CLOSE = '<!--/shiso-default-head-->';
|
|
22
|
+
|
|
23
|
+
const root = process.cwd();
|
|
24
|
+
const clientDir = path.join(root, 'dist', 'client');
|
|
25
|
+
const template = await readFile(path.join(clientDir, 'index.html'), 'utf8');
|
|
26
|
+
|
|
27
|
+
if (!template.includes('<!--app-html-->')) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'dist/client/index.html is missing the <!--app-html--> placeholder. ' +
|
|
30
|
+
'Run "vite build" again before prerendering (prerender consumes the template in place).',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { render, getRoutes, getRedirects, getSitemapEntries, getMarkdownPages, docsHomeUrl } =
|
|
35
|
+
await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href);
|
|
36
|
+
|
|
37
|
+
/** Vite's `base`, normalized to "" or "/prefix". */
|
|
38
|
+
function readBase() {
|
|
39
|
+
const match = template.match(/<script[^>]+src="([^"]*)\/assets\//);
|
|
40
|
+
const base = match?.[1] ?? '';
|
|
41
|
+
return base === '/' ? '' : base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const base = readBase();
|
|
45
|
+
|
|
46
|
+
function withBase(routePath) {
|
|
47
|
+
return `${base}${routePath}`.replace(/\/{2,}/g, '/') || '/';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fillTemplate(head, html, htmlAttrs) {
|
|
51
|
+
// Per-page head tags supersede the site-level defaults injected at build time.
|
|
52
|
+
let output = head
|
|
53
|
+
? template.replace(new RegExp(`${DEFAULT_HEAD_OPEN}[\\s\\S]*?${DEFAULT_HEAD_CLOSE}`), '')
|
|
54
|
+
: template;
|
|
55
|
+
|
|
56
|
+
// Per-page document language and direction, replacing the template's own.
|
|
57
|
+
if (htmlAttrs) {
|
|
58
|
+
output = output.replace(/<html([^>]*)>/, (_match, attrs) => {
|
|
59
|
+
const kept = attrs.replace(/\s+lang="[^"]*"/, '').replace(/\s+dir="[^"]*"/, '');
|
|
60
|
+
const dir = htmlAttrs.dir === 'rtl' ? ' dir="rtl"' : '';
|
|
61
|
+
return `<html${kept} lang="${htmlAttrs.lang}"${dir}>`;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return output.replace('<!--app-head-->', head).replace('<!--app-html-->', html);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writePage(outputPath, contents) {
|
|
69
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
70
|
+
await writeFile(outputPath, contents, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Maps a base-relative route to its output file, e.g. "/docs/a" -> "docs/a/index.html". */
|
|
74
|
+
function outputPathFor(routePath) {
|
|
75
|
+
const relative = withBase(routePath).replace(/^\//, '');
|
|
76
|
+
return path.join(clientDir, relative, 'index.html');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const routes = getRoutes();
|
|
80
|
+
|
|
81
|
+
for (const route of routes) {
|
|
82
|
+
const { html, head, htmlAttrs } = render(route);
|
|
83
|
+
await writePage(outputPathFor(route), fillTemplate(head, html, htmlAttrs));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Root entry. When the default scope's landing page is not the root itself the
|
|
87
|
+
// root is a redirect; a meta refresh alone is slow and SEO-hostile, so pair it
|
|
88
|
+
// with a canonical link and an immediate history-replacing navigation.
|
|
89
|
+
if (docsHomeUrl && docsHomeUrl !== '/') {
|
|
90
|
+
const target = withBase(`${docsHomeUrl}/`);
|
|
91
|
+
|
|
92
|
+
await writePage(
|
|
93
|
+
path.join(clientDir, 'index.html'),
|
|
94
|
+
fillTemplate(
|
|
95
|
+
[
|
|
96
|
+
`<link rel="canonical" href="${target}" />`,
|
|
97
|
+
`<meta http-equiv="refresh" content="0;url=${target}" />`,
|
|
98
|
+
`<script>location.replace(${JSON.stringify(target)});</script>`,
|
|
99
|
+
].join('\n '),
|
|
100
|
+
'',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Raw markdown next to every page: "/docs/installation" -> "docs/installation.md".
|
|
106
|
+
// Served for the contextual menu's copy/view options and for AI tools.
|
|
107
|
+
const markdownPages = getMarkdownPages();
|
|
108
|
+
|
|
109
|
+
for (const { route, filePath } of markdownPages) {
|
|
110
|
+
const source = await readFile(path.join(root, ...filePath.split('/').filter(Boolean)), 'utf8');
|
|
111
|
+
const relative = withBase(route).replace(/^\//, '') || 'index';
|
|
112
|
+
await writePage(path.join(clientDir, `${relative}.md`), source);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Redirect pages. Static hosting cannot serve real 301s, so each redirect
|
|
116
|
+
// gets the same canonical + meta refresh + immediate replace treatment as
|
|
117
|
+
// the root entry. Real pages always win over redirect rules.
|
|
118
|
+
const routeSet = new Set(routes.map(withBase));
|
|
119
|
+
const redirects = getRedirects();
|
|
120
|
+
|
|
121
|
+
function redirectTarget(destination) {
|
|
122
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(destination) ? destination : withBase(destination);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const { source, destination } of redirects) {
|
|
126
|
+
if (routeSet.has(withBase(source))) {
|
|
127
|
+
console.warn(`Redirect source "${source}" is an existing page — skipped.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const target = redirectTarget(destination);
|
|
132
|
+
|
|
133
|
+
await writePage(
|
|
134
|
+
outputPathFor(source),
|
|
135
|
+
fillTemplate(
|
|
136
|
+
[
|
|
137
|
+
`<link rel="canonical" href="${target}" />`,
|
|
138
|
+
`<meta name="robots" content="noindex" />`,
|
|
139
|
+
`<meta http-equiv="refresh" content="0;url=${target}" />`,
|
|
140
|
+
`<script>location.replace(${JSON.stringify(target)});</script>`,
|
|
141
|
+
].join('\n '),
|
|
142
|
+
'',
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sitemap, only when $shiso.siteUrl provides an absolute origin.
|
|
148
|
+
const sitemapEntries = getSitemapEntries();
|
|
149
|
+
|
|
150
|
+
if (sitemapEntries.length) {
|
|
151
|
+
const urls = sitemapEntries
|
|
152
|
+
.map(({ url, lastmod }) =>
|
|
153
|
+
[
|
|
154
|
+
' <url>',
|
|
155
|
+
` <loc>${url}</loc>`,
|
|
156
|
+
lastmod ? ` <lastmod>${lastmod}</lastmod>` : null,
|
|
157
|
+
' </url>',
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join('\n'),
|
|
161
|
+
)
|
|
162
|
+
.join('\n');
|
|
163
|
+
|
|
164
|
+
await writePage(
|
|
165
|
+
path.join(clientDir, 'sitemap.xml'),
|
|
166
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 404 fallback renders the app shell so client routing can take over
|
|
171
|
+
// on hosts that serve 404.html for unknown paths (e.g. GitHub Pages).
|
|
172
|
+
const notFound = render('/404');
|
|
173
|
+
await writePage(
|
|
174
|
+
path.join(clientDir, '404.html'),
|
|
175
|
+
fillTemplate(notFound.head, notFound.html, notFound.htmlAttrs),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Guard against a base/route mismatch silently producing unreachable files.
|
|
179
|
+
const stray = routes.filter(route => !withBase(route).startsWith(base || '/'));
|
|
180
|
+
|
|
181
|
+
if (stray.length) {
|
|
182
|
+
throw new Error(`Routes fall outside the deploy base "${base}": ${stray.join(', ')}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const extras = [
|
|
186
|
+
redirects.length ? `${redirects.length} redirects` : null,
|
|
187
|
+
sitemapEntries.length ? 'sitemap.xml' : null,
|
|
188
|
+
]
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.join(', ');
|
|
191
|
+
|
|
192
|
+
console.log(
|
|
193
|
+
`Prerendered ${routes.length} pages to ${path.relative(root, clientDir)}${extras ? ` (+ ${extras})` : ''}`,
|
|
194
|
+
);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates docs.json against docs.schema.json.
|
|
3
|
+
*
|
|
4
|
+
* The schema is the source of truth for the public config format. Documented
|
|
5
|
+
* settings are strictly validated and every other key is rejected.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { Ajv } from 'ajv';
|
|
10
|
+
import { loadDocsConfig, loadDocsSchema } from './load-docs-config.mjs';
|
|
11
|
+
|
|
12
|
+
const packageRoot = path.resolve(import.meta.dirname, '..');
|
|
13
|
+
|
|
14
|
+
/** Returns the top-level keys declared by the public config schema. */
|
|
15
|
+
export function getSchemaKeys(schema) {
|
|
16
|
+
return Object.keys(schema.properties || {});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function levenshtein(a, b) {
|
|
20
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
21
|
+
|
|
22
|
+
for (let j = 1; j <= b.length; j++) {
|
|
23
|
+
rows[0][j] = j;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (let i = 1; i <= a.length; i++) {
|
|
27
|
+
for (let j = 1; j <= b.length; j++) {
|
|
28
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
29
|
+
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return rows[a.length][b.length];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Nearest known key, when it is close enough that a typo is the likely cause. */
|
|
37
|
+
export function suggestKey(unknownKey, knownKeys) {
|
|
38
|
+
let best = null;
|
|
39
|
+
let bestDistance = Infinity;
|
|
40
|
+
|
|
41
|
+
for (const key of knownKeys) {
|
|
42
|
+
const distance = levenshtein(unknownKey.toLowerCase(), key.toLowerCase());
|
|
43
|
+
|
|
44
|
+
if (distance < bestDistance) {
|
|
45
|
+
best = key;
|
|
46
|
+
bestDistance = distance;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const threshold = Math.max(2, Math.floor(unknownKey.length / 3));
|
|
51
|
+
|
|
52
|
+
return bestDistance <= threshold ? best : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateConfig(config, schema) {
|
|
56
|
+
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
|
|
57
|
+
|
|
58
|
+
const validate = ajv.compile(schema);
|
|
59
|
+
const knownKeys = getSchemaKeys(schema);
|
|
60
|
+
|
|
61
|
+
if (!validate(config)) {
|
|
62
|
+
const errors = (validate.errors ?? []).map(error => {
|
|
63
|
+
const location = error.instancePath || '(root)';
|
|
64
|
+
const extra = error.params?.additionalProperty;
|
|
65
|
+
|
|
66
|
+
if (extra) {
|
|
67
|
+
const suggestion = !error.instancePath && suggestKey(extra, knownKeys);
|
|
68
|
+
|
|
69
|
+
return `${location} has unknown key "${extra}"${suggestion ? ` — did you mean "${suggestion}"?` : ''}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return `${location} ${error.message}`;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { valid: false, errors };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid: true, errors: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validates one consuming project against the schema bundled with this Shiso version. */
|
|
82
|
+
export async function validateProject({ root = process.cwd() } = {}) {
|
|
83
|
+
const [{ schema }, { config }] = await Promise.all([
|
|
84
|
+
loadDocsSchema({ root: packageRoot }),
|
|
85
|
+
loadDocsConfig({ root }),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
return validateConfig(config, schema);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// CLI entry point. Skipped when imported by tests.
|
|
92
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
93
|
+
const { valid, errors } = await validateProject();
|
|
94
|
+
|
|
95
|
+
if (!valid) {
|
|
96
|
+
console.error('docs.json failed schema validation:\n');
|
|
97
|
+
for (const error of errors) {
|
|
98
|
+
console.error(` ${error}`);
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('docs.json is valid.');
|
|
104
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadDocsConfig } from './load-docs-config.mjs';
|
|
3
|
+
|
|
4
|
+
export const VIRTUAL_DOCS_CONFIG_ID = 'virtual:shiso-docs-config';
|
|
5
|
+
const RESOLVED_DOCS_CONFIG_ID = `\0${VIRTUAL_DOCS_CONFIG_ID}`;
|
|
6
|
+
|
|
7
|
+
function renderConfigModule(config) {
|
|
8
|
+
return `export default ${JSON.stringify(config)};`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates the single docs-config state shared by a Vite build and application
|
|
13
|
+
* modules. The virtual module keeps Node-only file loading out of the browser
|
|
14
|
+
* bundle and gives the later `$ref` resolver one integration point.
|
|
15
|
+
*/
|
|
16
|
+
export async function createDocsConfigModule({
|
|
17
|
+
root = process.cwd(),
|
|
18
|
+
configFile = 'docs.json',
|
|
19
|
+
} = {}) {
|
|
20
|
+
const options = { root, configFile };
|
|
21
|
+
let loaded = await loadDocsConfig(options);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
getConfig: () => loaded.config,
|
|
25
|
+
getSourcePaths: () => loaded.sourcePaths,
|
|
26
|
+
sourcePath: loaded.sourcePath,
|
|
27
|
+
plugin: {
|
|
28
|
+
name: 'shiso-docs-config',
|
|
29
|
+
enforce: 'pre',
|
|
30
|
+
resolveId(id) {
|
|
31
|
+
return id === VIRTUAL_DOCS_CONFIG_ID ? RESOLVED_DOCS_CONFIG_ID : undefined;
|
|
32
|
+
},
|
|
33
|
+
load(id) {
|
|
34
|
+
if (id !== RESOLVED_DOCS_CONFIG_ID) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const sourcePath of loaded.sourcePaths) {
|
|
39
|
+
this.addWatchFile(sourcePath);
|
|
40
|
+
}
|
|
41
|
+
return renderConfigModule(loaded.config);
|
|
42
|
+
},
|
|
43
|
+
async handleHotUpdate(context) {
|
|
44
|
+
if (!loaded.sourcePaths.includes(path.resolve(context.file))) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
loaded = await loadDocsConfig(options);
|
|
49
|
+
|
|
50
|
+
const configModule = context.server.moduleGraph.getModuleById(RESOLVED_DOCS_CONFIG_ID);
|
|
51
|
+
|
|
52
|
+
if (configModule) {
|
|
53
|
+
context.server.moduleGraph.invalidateModule(configModule);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
context.server.ws.send({ type: 'full-reload' });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import '@fontsource/inter/400.css';
|
|
2
|
+
import '@fontsource/inter/700.css';
|
|
3
|
+
import '@fontsource/inter/800.css';
|
|
4
|
+
import '@fontsource/jetbrains-mono/400.css';
|
|
5
|
+
import 'highlight.js/styles/github.css';
|
|
6
|
+
import '@/styles/global.css';
|
|
7
|
+
|
|
8
|
+
import { MDXProvider } from '@mdx-js/react';
|
|
9
|
+
import { Navigate, Route, Routes } from 'react-router';
|
|
10
|
+
import { CodeBlock } from '@/components/CodeBlock';
|
|
11
|
+
import * as docsComponents from '@/components/docs/index';
|
|
12
|
+
import { Layout } from '@/components/Layout';
|
|
13
|
+
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
14
|
+
import { docsHomeUrl, siteModel } from '@/lib/site-config';
|
|
15
|
+
import { DocPage } from '@/pages/DocPage';
|
|
16
|
+
|
|
17
|
+
const mdxComponents = {
|
|
18
|
+
...docsComponents,
|
|
19
|
+
pre: CodeBlock,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function App() {
|
|
23
|
+
return (
|
|
24
|
+
<TooltipProvider>
|
|
25
|
+
<MDXProvider components={mdxComponents}>
|
|
26
|
+
<Layout site={siteModel}>
|
|
27
|
+
<Routes>
|
|
28
|
+
{/* When the default scope's landing page is the root there is nothing to redirect. */}
|
|
29
|
+
{docsHomeUrl !== '/' ? (
|
|
30
|
+
<Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
|
|
31
|
+
) : null}
|
|
32
|
+
<Route path="*" element={<DocPage site={siteModel} />} />
|
|
33
|
+
</Routes>
|
|
34
|
+
</Layout>
|
|
35
|
+
</MDXProvider>
|
|
36
|
+
</TooltipProvider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { X } from '@/components/icons';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { renderInlineMarkdown } from '@/lib/inline-markdown';
|
|
5
|
+
import type { BannerConfig } from '@/lib/types';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'shiso-banner-dismissed';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Site-wide banner from the `banner` config key. Dismissal stores the banner
|
|
11
|
+
* content, not just a flag, so publishing a new banner shows it again.
|
|
12
|
+
* The banner renders during prerender and hides after hydration when it was
|
|
13
|
+
* previously dismissed.
|
|
14
|
+
*/
|
|
15
|
+
export function Banner({
|
|
16
|
+
banner,
|
|
17
|
+
dismissLabel,
|
|
18
|
+
}: {
|
|
19
|
+
banner: BannerConfig | null;
|
|
20
|
+
dismissLabel: string;
|
|
21
|
+
}) {
|
|
22
|
+
const [dismissed, setDismissed] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!banner?.dismissible) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
setDismissed(localStorage.getItem(STORAGE_KEY) === banner.content);
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore storage failures (private mode, etc).
|
|
33
|
+
}
|
|
34
|
+
}, [banner?.dismissible, banner?.content]);
|
|
35
|
+
|
|
36
|
+
if (!banner || dismissed) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handleDismiss = () => {
|
|
41
|
+
setDismissed(true);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(STORAGE_KEY, banner.content);
|
|
45
|
+
} catch {
|
|
46
|
+
// Ignore storage failures (private mode, etc).
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="relative flex shrink-0 items-center justify-center gap-2 bg-primary px-10 py-2 text-center text-sm text-primary-foreground">
|
|
52
|
+
<div className="[&_a:hover]:opacity-[0.85] [&_a]:text-inherit [&_a]:underline [&_a]:underline-offset-2 [&_code]:rounded-sm [&_code]:bg-black/20 [&_code]:px-1 [&_code]:py-[0.0625rem] [&_code]:text-[0.8125rem] [&_code]:font-mono">
|
|
53
|
+
{renderInlineMarkdown(banner.content)}
|
|
54
|
+
</div>
|
|
55
|
+
{banner.dismissible && (
|
|
56
|
+
<Button
|
|
57
|
+
type="button"
|
|
58
|
+
variant="ghost"
|
|
59
|
+
size="icon-xs"
|
|
60
|
+
className="absolute top-1/2 right-3 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-sm text-inherit opacity-80 hover:bg-black/15 hover:opacity-100"
|
|
61
|
+
onClick={handleDismiss}
|
|
62
|
+
aria-label={dismissLabel}
|
|
63
|
+
>
|
|
64
|
+
<X size={16} />
|
|
65
|
+
</Button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ReactNode, useRef, useState } from 'react';
|
|
2
|
+
import { CheckIcon, Copy } from '@/components/icons';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
5
|
+
|
|
6
|
+
export interface CodeBlockProps {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CodeBlock({ children, className }: CodeBlockProps) {
|
|
12
|
+
const textInput = useRef<HTMLPreElement>(null);
|
|
13
|
+
const [copied, setCopied] = useState(false);
|
|
14
|
+
|
|
15
|
+
const handleCopy = () => {
|
|
16
|
+
setCopied(true);
|
|
17
|
+
navigator?.clipboard?.writeText(textInput.current?.textContent || '');
|
|
18
|
+
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
setCopied(false);
|
|
21
|
+
}, 1000);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div data-slot="code-block" className="relative my-5 overflow-hidden rounded-lg bg-muted/50">
|
|
26
|
+
<ScrollArea scrollbars="horizontal" className="w-full">
|
|
27
|
+
<pre
|
|
28
|
+
ref={textInput}
|
|
29
|
+
className={`code-block p-3 pr-12 text-sm text-foreground leading-[1.6] font-mono ${className || ''}`}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</pre>
|
|
33
|
+
</ScrollArea>
|
|
34
|
+
<Button
|
|
35
|
+
type="button"
|
|
36
|
+
variant="ghost"
|
|
37
|
+
size="icon-sm"
|
|
38
|
+
className="absolute top-2.5 right-3 inline-flex size-7 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
39
|
+
onClick={handleCopy}
|
|
40
|
+
aria-label="Copy code"
|
|
41
|
+
>
|
|
42
|
+
{copied ? <CheckIcon className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
|
|
43
|
+
</Button>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isKnownPlatform, SocialIcon } from '@/components/SocialIcon';
|
|
2
|
+
import { getIcon } from '@/lib/icons';
|
|
3
|
+
|
|
4
|
+
export function ConfiguredIcon({ icon, size = 14 }: { icon?: string; size?: number }) {
|
|
5
|
+
if (!icon) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (isKnownPlatform(icon)) {
|
|
10
|
+
return <SocialIcon platform={icon} size={size} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const Icon = getIcon(icon);
|
|
14
|
+
return Icon ? <Icon size={size} aria-hidden="true" /> : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ConfiguredIcon } from '@/components/ConfiguredIcon';
|
|
3
|
+
import { Check, ChevronRight } from '@/components/icons';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from '@/components/ui/dropdown-menu';
|
|
11
|
+
import type { ResolvedContextualOption, ThemeLabels } from '@/lib/types';
|
|
12
|
+
|
|
13
|
+
export function ContextualMenu({
|
|
14
|
+
options,
|
|
15
|
+
labels,
|
|
16
|
+
}: {
|
|
17
|
+
options: ResolvedContextualOption[];
|
|
18
|
+
labels: ThemeLabels;
|
|
19
|
+
}) {
|
|
20
|
+
const [copied, setCopied] = useState(false);
|
|
21
|
+
const primary = options[0];
|
|
22
|
+
|
|
23
|
+
if (!primary) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const runOption = async (option: ResolvedContextualOption) => {
|
|
28
|
+
if (option.action === 'copy') {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(option.href);
|
|
31
|
+
const text = response.ok ? await response.text() : window.location.href;
|
|
32
|
+
await navigator.clipboard.writeText(text);
|
|
33
|
+
setCopied(true);
|
|
34
|
+
setTimeout(() => setCopied(false), 1500);
|
|
35
|
+
} catch {
|
|
36
|
+
// Clipboard unavailable; leave the control unchanged.
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
window.open(option.href, option.target, option.target === '_blank' ? 'noreferrer' : undefined);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const optionIcon = (option: ResolvedContextualOption, showCopied = false) =>
|
|
45
|
+
showCopied && copied ? <Check className="size-3.5" /> : <ConfiguredIcon icon={option.icon} />;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="inline-flex shrink-0 items-stretch">
|
|
49
|
+
<Button
|
|
50
|
+
variant="outline"
|
|
51
|
+
size="sm"
|
|
52
|
+
className="rounded-r-none bg-card text-foreground only:rounded-md"
|
|
53
|
+
onClick={() => runOption(primary)}
|
|
54
|
+
>
|
|
55
|
+
{optionIcon(primary, primary.action === 'copy')}
|
|
56
|
+
{primary.action === 'copy' && copied ? labels.copied : primary.title}
|
|
57
|
+
</Button>
|
|
58
|
+
{options.length > 1 && (
|
|
59
|
+
<DropdownMenu>
|
|
60
|
+
<DropdownMenuTrigger
|
|
61
|
+
render={
|
|
62
|
+
<Button
|
|
63
|
+
variant="outline"
|
|
64
|
+
size="icon-sm"
|
|
65
|
+
className="rounded-l-none border-l-0 bg-card"
|
|
66
|
+
aria-label={labels.moreOptions}
|
|
67
|
+
/>
|
|
68
|
+
}
|
|
69
|
+
>
|
|
70
|
+
<ChevronRight className="size-3.5 rotate-90" />
|
|
71
|
+
</DropdownMenuTrigger>
|
|
72
|
+
<DropdownMenuContent align="end" className="w-60">
|
|
73
|
+
{options.map(option => (
|
|
74
|
+
<DropdownMenuItem
|
|
75
|
+
key={option.key}
|
|
76
|
+
className="flex-row items-start gap-2 px-2.5 py-1.5"
|
|
77
|
+
onClick={() => runOption(option)}
|
|
78
|
+
>
|
|
79
|
+
<span className="mt-0.5">{optionIcon(option)}</span>
|
|
80
|
+
<span className="flex flex-col gap-0.5">
|
|
81
|
+
<span className="text-[0.85rem] font-medium text-foreground">{option.title}</span>
|
|
82
|
+
{option.description ? (
|
|
83
|
+
<span className="text-xs text-muted-foreground">{option.description}</span>
|
|
84
|
+
) : null}
|
|
85
|
+
</span>
|
|
86
|
+
</DropdownMenuItem>
|
|
87
|
+
))}
|
|
88
|
+
</DropdownMenuContent>
|
|
89
|
+
</DropdownMenu>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|