@umami/shiso 0.54.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.
- package/CHANGELOG.md +12 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +835 -0
- package/mdx.config.ts +143 -0
- package/package.json +73 -83
- package/scripts/check-package.mjs +18 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +252 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +187 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +39 -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 +100 -0
- package/src/components/Docs.tsx +126 -0
- package/src/components/Footer.tsx +84 -0
- package/src/components/Header.tsx +72 -0
- package/src/components/Layout.tsx +16 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +208 -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/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 +77 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +682 -0
- package/src/lib/head.ts +220 -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/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 +80 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +100 -0
- package/src/lib/site-config.ts +104 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +478 -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 +17 -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,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,39 @@
|
|
|
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 { DOCS_PREFIX } from '@/lib/paths';
|
|
15
|
+
import { siteModel } from '@/lib/site-config';
|
|
16
|
+
import { DocPage } from '@/pages/DocPage';
|
|
17
|
+
|
|
18
|
+
const mdxComponents = {
|
|
19
|
+
...docsComponents,
|
|
20
|
+
pre: CodeBlock,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function App() {
|
|
24
|
+
return (
|
|
25
|
+
<TooltipProvider>
|
|
26
|
+
<MDXProvider components={mdxComponents}>
|
|
27
|
+
<Layout site={siteModel}>
|
|
28
|
+
<Routes>
|
|
29
|
+
{/* When docs are mounted at the site root there is nothing to redirect. */}
|
|
30
|
+
{DOCS_PREFIX ? (
|
|
31
|
+
<Route path="/" element={<Navigate to={DOCS_PREFIX} replace />} />
|
|
32
|
+
) : null}
|
|
33
|
+
<Route path="*" element={<DocPage site={siteModel} />} />
|
|
34
|
+
</Routes>
|
|
35
|
+
</Layout>
|
|
36
|
+
</MDXProvider>
|
|
37
|
+
</TooltipProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Link } from 'react-router';
|
|
2
|
+
import { ContextualMenu } from '@/components/ContextualMenu';
|
|
3
|
+
import { ChevronRight } from '@/components/icons';
|
|
4
|
+
import { getLastModified } from '@/lib/content';
|
|
5
|
+
import { resolveContextualOptions } from '@/lib/site-model';
|
|
6
|
+
import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
|
|
7
|
+
|
|
8
|
+
export interface DocContentProps {
|
|
9
|
+
page: NormalizedDocsPage;
|
|
10
|
+
doc: DocModule;
|
|
11
|
+
site: SiteModel;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DocContent({ page, doc, site }: DocContentProps) {
|
|
15
|
+
const pagerPages = site.docs.pages.filter(item => !item.hidden);
|
|
16
|
+
const pageIndex = pagerPages.findIndex(item => item.slug === page.slug);
|
|
17
|
+
const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : undefined;
|
|
18
|
+
const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : undefined;
|
|
19
|
+
const title = doc.frontmatter?.title || page.label;
|
|
20
|
+
const description = doc.frontmatter?.description;
|
|
21
|
+
const Content = doc.default;
|
|
22
|
+
const shouldShowTimestamp =
|
|
23
|
+
typeof doc.frontmatter?.timestamp === 'boolean'
|
|
24
|
+
? doc.frontmatter.timestamp
|
|
25
|
+
: site.showTimestamp;
|
|
26
|
+
const lastModified = shouldShowTimestamp ? getLastModified(page.filePath) : undefined;
|
|
27
|
+
// `styling.eyebrows`: the section name alone, or the full navigation path.
|
|
28
|
+
const eyebrow =
|
|
29
|
+
site.styling.eyebrows === 'breadcrumbs'
|
|
30
|
+
? [...new Set([page.tabLabel, page.section])].filter(Boolean).join(' / ')
|
|
31
|
+
: page.section;
|
|
32
|
+
const contextualOptions = resolveContextualOptions(site.contextualOptions, page, site.labels);
|
|
33
|
+
const dateFormat = new Intl.DateTimeFormat(site.locale, {
|
|
34
|
+
dateStyle: 'medium',
|
|
35
|
+
timeZone: 'UTC',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<article className="min-w-0 grow">
|
|
40
|
+
{eyebrow && <div className="text-sm font-bold text-primary">{eyebrow}</div>}
|
|
41
|
+
<div className="flex items-start justify-between gap-4">
|
|
42
|
+
{title && (
|
|
43
|
+
<h1 className="mt-2 text-4xl text-foreground leading-[1.2] tracking-[-0.03em] [font-family:var(--font-heading)] [font-weight:var(--font-heading-weight,800)]">
|
|
44
|
+
{title}
|
|
45
|
+
</h1>
|
|
46
|
+
)}
|
|
47
|
+
<ContextualMenu options={contextualOptions} labels={site.labels} />
|
|
48
|
+
</div>
|
|
49
|
+
{description && <p className="mt-1 mb-6 text-base text-muted-foreground">{description}</p>}
|
|
50
|
+
<div className="docs-markdown">
|
|
51
|
+
<Content />
|
|
52
|
+
</div>
|
|
53
|
+
{lastModified && (
|
|
54
|
+
<div className="mt-8 text-sm text-muted-foreground">
|
|
55
|
+
{site.labels.lastUpdated}{' '}
|
|
56
|
+
<time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
<div className="mt-8 flex items-center justify-between">
|
|
60
|
+
<NavigationButton {...prev} isPrev />
|
|
61
|
+
<NavigationButton {...next} />
|
|
62
|
+
</div>
|
|
63
|
+
</article>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NavigationButton = ({
|
|
68
|
+
label,
|
|
69
|
+
url,
|
|
70
|
+
isPrev,
|
|
71
|
+
}: {
|
|
72
|
+
label?: string;
|
|
73
|
+
url?: string;
|
|
74
|
+
isPrev?: boolean;
|
|
75
|
+
}) => {
|
|
76
|
+
if (!url || !label) {
|
|
77
|
+
return <div />;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Link
|
|
82
|
+
to={url}
|
|
83
|
+
className="group my-3 inline-flex items-center gap-3 text-base font-bold text-foreground"
|
|
84
|
+
>
|
|
85
|
+
{isPrev && (
|
|
86
|
+
<ChevronRight
|
|
87
|
+
size={14}
|
|
88
|
+
className="rotate-180 text-muted-foreground transition-colors group-hover:text-foreground"
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
{label}
|
|
92
|
+
{!isPrev && (
|
|
93
|
+
<ChevronRight
|
|
94
|
+
size={14}
|
|
95
|
+
className="text-muted-foreground transition-colors group-hover:text-foreground"
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</Link>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
|
+
import { DocContent } from '@/components/DocContent';
|
|
4
|
+
import { Footer } from '@/components/Footer';
|
|
5
|
+
import { Menu } from '@/components/icons';
|
|
6
|
+
import { PageLinks } from '@/components/PageLinks';
|
|
7
|
+
import { SideNav } from '@/components/SideNav';
|
|
8
|
+
import { Button } from '@/components/ui/button';
|
|
9
|
+
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
|
10
|
+
import { renderInlineMarkdown } from '@/lib/inline-markdown';
|
|
11
|
+
import { DOCS_PREFIX } from '@/lib/paths';
|
|
12
|
+
import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 404 view driven by the `errors.404` config key. The standard defaults to
|
|
16
|
+
* redirecting home; that happens after hydration rather than during render,
|
|
17
|
+
* because the prerendered 404.html must stay a static page for hosts that
|
|
18
|
+
* serve it for every unknown path.
|
|
19
|
+
*/
|
|
20
|
+
function NotFound({ site }: { site: SiteModel }) {
|
|
21
|
+
const navigate = useNavigate();
|
|
22
|
+
const { redirect, title, description } = site.error404;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (redirect) {
|
|
26
|
+
navigate(DOCS_PREFIX || '/', { replace: true });
|
|
27
|
+
}
|
|
28
|
+
}, [redirect, navigate]);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="py-16 text-center">
|
|
32
|
+
<h1>{title || site.labels.notFound}</h1>
|
|
33
|
+
{description && <p>{renderInlineMarkdown(description)}</p>}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DocsProps {
|
|
39
|
+
page: NormalizedDocsPage | null;
|
|
40
|
+
doc: DocModule | null;
|
|
41
|
+
site: SiteModel;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Docs({ page, doc, site }: DocsProps) {
|
|
45
|
+
const { pathname } = useLocation();
|
|
46
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
47
|
+
const { tabs, navigation } = site.docs;
|
|
48
|
+
|
|
49
|
+
// Close the mobile menu and start each newly loaded page at the top. Hash
|
|
50
|
+
// links keep their native section-scrolling behavior.
|
|
51
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setMenuOpen(false);
|
|
54
|
+
if (!window.location.hash) {
|
|
55
|
+
window.scrollTo({ top: 0, left: 0 });
|
|
56
|
+
}
|
|
57
|
+
}, [pathname]);
|
|
58
|
+
|
|
59
|
+
if (!page || !doc) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex min-h-full flex-col">
|
|
62
|
+
<div className="grow">
|
|
63
|
+
<NotFound site={site} />
|
|
64
|
+
</div>
|
|
65
|
+
<Footer footer={site.footer} />
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex min-h-full flex-col gap-6 lg:gap-0">
|
|
72
|
+
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
|
73
|
+
<div className="flex justify-end lg:hidden">
|
|
74
|
+
<SheetTrigger render={<Button variant="outline" className="bg-card" />}>
|
|
75
|
+
<Menu className="size-3.5" />
|
|
76
|
+
{site.labels.menu}
|
|
77
|
+
</SheetTrigger>
|
|
78
|
+
</div>
|
|
79
|
+
<SheetContent
|
|
80
|
+
side="right"
|
|
81
|
+
className="w-[min(320px,85vw)] gap-0 overflow-y-auto bg-background p-4 sm:max-w-80"
|
|
82
|
+
>
|
|
83
|
+
<SheetTitle className="sr-only">{site.labels.documentationNavigation}</SheetTitle>
|
|
84
|
+
<div className="pt-8">
|
|
85
|
+
<SideNav
|
|
86
|
+
tabs={tabs}
|
|
87
|
+
navigation={navigation}
|
|
88
|
+
anchors={site.docs.anchors}
|
|
89
|
+
drilldown={site.drilldown}
|
|
90
|
+
navigationLabel={site.labels.documentationNavigation}
|
|
91
|
+
expandLabel={site.labels.expand}
|
|
92
|
+
collapseLabel={site.labels.collapse}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</SheetContent>
|
|
96
|
+
</Sheet>
|
|
97
|
+
<div className="flex items-start gap-12 lg:min-h-[calc(100dvh-var(--header-height))] lg:pt-6">
|
|
98
|
+
<div className="hidden min-w-0 max-w-60 basis-60 self-start lg:sticky lg:top-[calc(var(--header-height)+1.5rem)] lg:block lg:h-[calc(100dvh-var(--header-height)-3rem)] lg:shrink-0">
|
|
99
|
+
<SideNav
|
|
100
|
+
tabs={tabs}
|
|
101
|
+
navigation={navigation}
|
|
102
|
+
anchors={site.docs.anchors}
|
|
103
|
+
isSticky
|
|
104
|
+
drilldown={site.drilldown}
|
|
105
|
+
navigationLabel={site.labels.documentationNavigation}
|
|
106
|
+
expandLabel={site.labels.expand}
|
|
107
|
+
collapseLabel={site.labels.collapse}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex min-w-0 grow self-stretch flex-col">
|
|
111
|
+
<div className="flex grow items-start gap-12">
|
|
112
|
+
<DocContent page={page} doc={doc} site={site} />
|
|
113
|
+
<div className="hidden min-w-0 max-w-60 basis-60 self-start lg:sticky lg:top-[calc(var(--header-height)+1.5rem)] lg:block lg:shrink-0">
|
|
114
|
+
<PageLinks
|
|
115
|
+
items={doc.toc}
|
|
116
|
+
title={site.labels.tableOfContents}
|
|
117
|
+
navigationLabel={site.labels.tableOfContentsNavigation}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<Footer footer={site.footer} className="lg:mr-72" />
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|