@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,217 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
|
+
import { Search as SearchIcon } from '@/components/icons';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import {
|
|
6
|
+
Command,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
} from '@/components/ui/command';
|
|
13
|
+
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
14
|
+
import type { SearchResult } from '@/lib/search';
|
|
15
|
+
import type { ResolvedSearchConfig } from '@/lib/search/config';
|
|
16
|
+
import { resolveSearchProvider, type SearchProvider } from '@/lib/search/provider';
|
|
17
|
+
import { getScopeByPathname } from '@/lib/site-config';
|
|
18
|
+
import type { ThemeLabels } from '@/lib/types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Provider-neutral search dialog. The selected provider and its index or
|
|
22
|
+
* client are loaded on demand, so search stays out of the initial bundle.
|
|
23
|
+
*/
|
|
24
|
+
export function Search({ config, labels }: { config: ResolvedSearchConfig; labels: ThemeLabels }) {
|
|
25
|
+
const navigate = useNavigate();
|
|
26
|
+
const { pathname } = useLocation();
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [query, setQuery] = useState('');
|
|
29
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
30
|
+
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
|
31
|
+
const provider = useRef<Promise<SearchProvider> | null>(null);
|
|
32
|
+
const requestId = useRef(0);
|
|
33
|
+
|
|
34
|
+
const loadProvider = useCallback(() => {
|
|
35
|
+
if (!provider.current) {
|
|
36
|
+
provider.current = resolveSearchProvider(config.provider, config.options)
|
|
37
|
+
.then(resolved => {
|
|
38
|
+
if (resolved.fellBack) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[shiso] Unknown search provider "${config.provider}" — using the built-in local provider.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return resolved.provider;
|
|
45
|
+
})
|
|
46
|
+
.catch(error => {
|
|
47
|
+
provider.current = null;
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return provider.current;
|
|
53
|
+
}, [config.options, config.provider]);
|
|
54
|
+
|
|
55
|
+
const runQuery = useCallback(
|
|
56
|
+
async (value: string) => {
|
|
57
|
+
const currentRequest = ++requestId.current;
|
|
58
|
+
setQuery(value);
|
|
59
|
+
|
|
60
|
+
if (!value.trim()) {
|
|
61
|
+
setResults([]);
|
|
62
|
+
setStatus('idle');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setResults([]);
|
|
67
|
+
setStatus('loading');
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const activeProvider = await loadProvider();
|
|
71
|
+
// Search stays inside the version/language scope being browsed.
|
|
72
|
+
// Providers that predate the context argument simply ignore it.
|
|
73
|
+
const scope = getScopeByPathname(pathname);
|
|
74
|
+
const nextResults = await activeProvider.search(value, undefined, {
|
|
75
|
+
scopeId: scope.id,
|
|
76
|
+
language: scope.language,
|
|
77
|
+
version: scope.version,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (currentRequest === requestId.current) {
|
|
81
|
+
setResults(nextResults);
|
|
82
|
+
setStatus('ready');
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (currentRequest === requestId.current) {
|
|
86
|
+
console.error('[shiso] Search provider failed:', error);
|
|
87
|
+
setResults([]);
|
|
88
|
+
setStatus('error');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
[loadProvider, pathname],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const openDialog = useCallback(() => {
|
|
96
|
+
if (!config.enabled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setOpen(true);
|
|
101
|
+
void loadProvider().catch(error => {
|
|
102
|
+
console.error('[shiso] Search provider failed to load:', error);
|
|
103
|
+
});
|
|
104
|
+
}, [config.enabled, loadProvider]);
|
|
105
|
+
|
|
106
|
+
const closeDialog = useCallback(() => {
|
|
107
|
+
requestId.current += 1;
|
|
108
|
+
setOpen(false);
|
|
109
|
+
setQuery('');
|
|
110
|
+
setResults([]);
|
|
111
|
+
setStatus('idle');
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Global shortcut configured by docs.json.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
117
|
+
if (
|
|
118
|
+
config.enabled &&
|
|
119
|
+
config.shortcut &&
|
|
120
|
+
(event.metaKey || event.ctrlKey) &&
|
|
121
|
+
event.key.toLowerCase() === config.shortcut
|
|
122
|
+
) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
openDialog();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
window.addEventListener('keydown', onKeyDown);
|
|
129
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
130
|
+
}, [config.enabled, config.shortcut, openDialog]);
|
|
131
|
+
|
|
132
|
+
const select = (result: SearchResult) => {
|
|
133
|
+
closeDialog();
|
|
134
|
+
navigate(result.url);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (!config.enabled) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const hasQuery = !!query.trim();
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Dialog open={open} onOpenChange={nextOpen => (nextOpen ? openDialog() : closeDialog())}>
|
|
145
|
+
<DialogTrigger
|
|
146
|
+
render={
|
|
147
|
+
<Button
|
|
148
|
+
variant="outline"
|
|
149
|
+
className="h-auto gap-2 rounded-md bg-card px-2.5 py-1.5 text-muted-foreground hover:border-input hover:bg-card hover:text-foreground"
|
|
150
|
+
/>
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
<SearchIcon className="size-3.5" />
|
|
154
|
+
<span className="min-w-24 text-left">{config.prompt}</span>
|
|
155
|
+
{config.shortcut ? (
|
|
156
|
+
<kbd className="rounded-sm border border-border bg-muted px-[0.3rem] py-[0.05rem] text-[0.7rem] font-sans">
|
|
157
|
+
{config.shortcutLabel}
|
|
158
|
+
</kbd>
|
|
159
|
+
) : null}
|
|
160
|
+
</DialogTrigger>
|
|
161
|
+
<DialogContent
|
|
162
|
+
showCloseButton={false}
|
|
163
|
+
overlayClassName="bg-black/40 supports-backdrop-filter:backdrop-blur-none"
|
|
164
|
+
className="top-[10vh] max-w-[calc(100%-2rem)] -translate-y-0 gap-0 overflow-hidden rounded-lg border border-border bg-background p-0 ring-0 shadow-[0_10px_40px_rgba(0,0,0,0.2)] sm:max-w-[34rem]"
|
|
165
|
+
>
|
|
166
|
+
<DialogTitle className="sr-only">{labels.searchTitle}</DialogTitle>
|
|
167
|
+
<Command shouldFilter={false} className="rounded-none bg-background p-0">
|
|
168
|
+
<CommandInput
|
|
169
|
+
autoFocus
|
|
170
|
+
className="text-base md:text-base"
|
|
171
|
+
placeholder={config.prompt}
|
|
172
|
+
value={query}
|
|
173
|
+
onValueChange={runQuery}
|
|
174
|
+
onKeyDownCapture={event => {
|
|
175
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
176
|
+
event.stopPropagation();
|
|
177
|
+
}
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
<CommandList className="max-h-[50vh] border-border border-t">
|
|
181
|
+
{hasQuery ? (
|
|
182
|
+
<CommandEmpty>
|
|
183
|
+
{status === 'loading'
|
|
184
|
+
? labels.searching
|
|
185
|
+
: status === 'error'
|
|
186
|
+
? labels.searchUnavailable
|
|
187
|
+
: labels.noResults}
|
|
188
|
+
</CommandEmpty>
|
|
189
|
+
) : null}
|
|
190
|
+
{hasQuery && results.length ? (
|
|
191
|
+
<CommandGroup className="p-2">
|
|
192
|
+
{results.map(result => (
|
|
193
|
+
<CommandItem
|
|
194
|
+
key={result.url}
|
|
195
|
+
value={result.url}
|
|
196
|
+
className="block whitespace-normal rounded-md px-3 py-2 text-left [&>svg:last-child]:hidden"
|
|
197
|
+
onSelect={() => select(result)}
|
|
198
|
+
>
|
|
199
|
+
<div className="text-[0.9rem] font-semibold text-foreground">
|
|
200
|
+
{result.page}
|
|
201
|
+
{result.heading ? ` › ${result.heading}` : ''}
|
|
202
|
+
</div>
|
|
203
|
+
{result.snippet && (
|
|
204
|
+
<div className="mt-[0.15rem] line-clamp-2 text-[0.8rem] text-muted-foreground">
|
|
205
|
+
{result.snippet}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</CommandItem>
|
|
209
|
+
))}
|
|
210
|
+
</CommandGroup>
|
|
211
|
+
) : null}
|
|
212
|
+
</CommandList>
|
|
213
|
+
</Command>
|
|
214
|
+
</DialogContent>
|
|
215
|
+
</Dialog>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { type ReactNode, useEffect, useState } from 'react';
|
|
3
|
+
import { Link, useLocation, useNavigate } from 'react-router';
|
|
4
|
+
import { resolveIcon } from '@/components/docs/utils';
|
|
5
|
+
import { ChevronRight, ExternalLink } from '@/components/icons';
|
|
6
|
+
import { Badge } from '@/components/ui/badge';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
9
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
10
|
+
import { flattenNav, isNodeHidden } from '@/lib/docs-config';
|
|
11
|
+
import type { DocsTab, NavGroupNode, NavNode } from '@/lib/types';
|
|
12
|
+
|
|
13
|
+
const sectionLabelClass =
|
|
14
|
+
'flex min-w-0 flex-1 items-center gap-[0.4rem] pb-2 pr-1 font-bold text-inherit';
|
|
15
|
+
const groupLabelClass =
|
|
16
|
+
'flex min-w-0 flex-1 items-center gap-[0.4rem] px-3 py-2 font-medium text-inherit';
|
|
17
|
+
const selectedClass =
|
|
18
|
+
'border-l-sidebar-primary font-medium text-sidebar-primary hover:text-sidebar-primary';
|
|
19
|
+
|
|
20
|
+
export interface SideNavProps {
|
|
21
|
+
tabs: DocsTab[];
|
|
22
|
+
navigation: Record<string, NavNode[]>;
|
|
23
|
+
anchors: NavNode[];
|
|
24
|
+
isSticky?: boolean;
|
|
25
|
+
drilldown?: boolean;
|
|
26
|
+
navigationLabel: string;
|
|
27
|
+
expandLabel: string;
|
|
28
|
+
collapseLabel: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when the group's root or any descendant page is the current page. */
|
|
32
|
+
function containsPage(node: NavGroupNode, pathname: string): boolean {
|
|
33
|
+
if (node.root?.page.url === pathname) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return flattenNav(node.children).some(page => page.url === pathname);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Group section. Groups with `collapsible: false` stay open; otherwise,
|
|
42
|
+
* top-level groups start expanded and nested groups start collapsed unless
|
|
43
|
+
* `expanded: true` or the current page is inside.
|
|
44
|
+
*
|
|
45
|
+
* Click behavior follows `interaction.drilldown`:
|
|
46
|
+
* - true: expanding also navigates to the group's root or first page
|
|
47
|
+
* - false: the header only expands/collapses
|
|
48
|
+
* - unset: headers with a root page navigate, others toggle
|
|
49
|
+
*/
|
|
50
|
+
function CollapsibleGroup({
|
|
51
|
+
node,
|
|
52
|
+
pathname,
|
|
53
|
+
depth,
|
|
54
|
+
drilldown,
|
|
55
|
+
expandLabel,
|
|
56
|
+
collapseLabel,
|
|
57
|
+
}: {
|
|
58
|
+
node: NavGroupNode;
|
|
59
|
+
pathname: string;
|
|
60
|
+
depth: number;
|
|
61
|
+
drilldown?: boolean;
|
|
62
|
+
expandLabel: string;
|
|
63
|
+
collapseLabel: string;
|
|
64
|
+
}) {
|
|
65
|
+
const navigate = useNavigate();
|
|
66
|
+
const active = containsPage(node, pathname);
|
|
67
|
+
const collapsible = node.collapsible !== false;
|
|
68
|
+
// Top-level sections stay open by default so the sidebar is usable on first load.
|
|
69
|
+
const [expanded, setExpanded] = useState(
|
|
70
|
+
!collapsible || depth === 0 || !!node.expanded || active,
|
|
71
|
+
);
|
|
72
|
+
const isExpanded = !collapsible || expanded;
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!collapsible || active) {
|
|
76
|
+
setExpanded(true);
|
|
77
|
+
}
|
|
78
|
+
}, [active, collapsible]);
|
|
79
|
+
|
|
80
|
+
const firstPage = node.root?.page || flattenNav(node.children).find(page => !page.hidden);
|
|
81
|
+
|
|
82
|
+
const handleOpenChange = (next: boolean) => {
|
|
83
|
+
setExpanded(next);
|
|
84
|
+
|
|
85
|
+
if (next && drilldown === true && firstPage) {
|
|
86
|
+
navigate(firstPage.url);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const chevron = (
|
|
91
|
+
<ChevronRight className="size-3.5 shrink-0 origin-center text-muted-foreground transition-[color,transform] duration-150 group-hover/section:text-sidebar-accent-foreground group-aria-expanded/collapsible-trigger:rotate-90" />
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const isTopLevel = depth === 0;
|
|
95
|
+
const rootSelected = node.root?.page.url === pathname;
|
|
96
|
+
const headerClass = isTopLevel
|
|
97
|
+
? 'flex min-w-0 items-center text-sidebar-foreground'
|
|
98
|
+
: classNames(
|
|
99
|
+
'flex items-center border-sidebar-border border-l text-muted-foreground hover:text-sidebar-accent-foreground',
|
|
100
|
+
{ 'pl-6': depth > 1 },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const fixedHeader = (
|
|
104
|
+
<div
|
|
105
|
+
className={classNames(headerClass, {
|
|
106
|
+
[selectedClass]: rootSelected && !isTopLevel,
|
|
107
|
+
'text-sidebar-primary': rootSelected && isTopLevel,
|
|
108
|
+
})}
|
|
109
|
+
>
|
|
110
|
+
{node.root ? (
|
|
111
|
+
<Link to={node.root.page.url} className={isTopLevel ? sectionLabelClass : groupLabelClass}>
|
|
112
|
+
{resolveIcon(node.icon)}
|
|
113
|
+
{node.label}
|
|
114
|
+
</Link>
|
|
115
|
+
) : (
|
|
116
|
+
<span className={isTopLevel ? sectionLabelClass : groupLabelClass}>
|
|
117
|
+
{resolveIcon(node.icon)}
|
|
118
|
+
{node.label}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Navigating collapsible headers keep a separate chevron button so collapse stays reachable.
|
|
125
|
+
const header = !collapsible ? (
|
|
126
|
+
fixedHeader
|
|
127
|
+
) : drilldown !== false && node.root ? (
|
|
128
|
+
<div
|
|
129
|
+
className={classNames(headerClass, 'group/section flex items-center', {
|
|
130
|
+
[selectedClass]: rootSelected && !isTopLevel,
|
|
131
|
+
'text-sidebar-primary': rootSelected && isTopLevel,
|
|
132
|
+
})}
|
|
133
|
+
>
|
|
134
|
+
<Link to={node.root.page.url} className={isTopLevel ? sectionLabelClass : groupLabelClass}>
|
|
135
|
+
{resolveIcon(node.icon)}
|
|
136
|
+
{node.label}
|
|
137
|
+
</Link>
|
|
138
|
+
<CollapsibleTrigger
|
|
139
|
+
render={
|
|
140
|
+
<Button
|
|
141
|
+
type="button"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="icon-xs"
|
|
144
|
+
className={classNames(
|
|
145
|
+
'group/collapsible-trigger inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
146
|
+
{ 'mr-[0.35rem]': !isTopLevel },
|
|
147
|
+
)}
|
|
148
|
+
aria-label={`${isExpanded ? collapseLabel : expandLabel} ${node.label}`}
|
|
149
|
+
/>
|
|
150
|
+
}
|
|
151
|
+
>
|
|
152
|
+
{chevron}
|
|
153
|
+
</CollapsibleTrigger>
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<CollapsibleTrigger
|
|
157
|
+
render={
|
|
158
|
+
<Button
|
|
159
|
+
type="button"
|
|
160
|
+
variant="ghost"
|
|
161
|
+
className={classNames(
|
|
162
|
+
headerClass,
|
|
163
|
+
'group/section group/collapsible-trigger flex h-auto w-full items-center justify-start gap-[0.4rem] whitespace-normal rounded-none px-0 py-0 text-left hover:bg-transparent aria-expanded:bg-transparent dark:hover:bg-transparent [&>svg:last-child]:ml-auto',
|
|
164
|
+
{
|
|
165
|
+
'pb-2 font-bold text-sidebar-foreground': isTopLevel,
|
|
166
|
+
'border-sidebar-border border-l px-3 py-2 font-medium text-muted-foreground hover:text-sidebar-accent-foreground':
|
|
167
|
+
!isTopLevel,
|
|
168
|
+
},
|
|
169
|
+
)}
|
|
170
|
+
/>
|
|
171
|
+
}
|
|
172
|
+
>
|
|
173
|
+
{resolveIcon(node.icon)}
|
|
174
|
+
{node.label}
|
|
175
|
+
{chevron}
|
|
176
|
+
</CollapsibleTrigger>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!collapsible) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="flex flex-col">
|
|
182
|
+
{fixedHeader}
|
|
183
|
+
<div className="flex flex-col">
|
|
184
|
+
<NavNodes
|
|
185
|
+
nodes={node.children}
|
|
186
|
+
pathname={pathname}
|
|
187
|
+
depth={depth + 1}
|
|
188
|
+
drilldown={drilldown}
|
|
189
|
+
expandLabel={expandLabel}
|
|
190
|
+
collapseLabel={collapseLabel}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Collapsible open={expanded} onOpenChange={handleOpenChange} className="flex flex-col">
|
|
199
|
+
{header}
|
|
200
|
+
<CollapsibleContent>
|
|
201
|
+
<div className="flex flex-col">
|
|
202
|
+
<NavNodes
|
|
203
|
+
nodes={node.children}
|
|
204
|
+
pathname={pathname}
|
|
205
|
+
depth={depth + 1}
|
|
206
|
+
drilldown={drilldown}
|
|
207
|
+
expandLabel={expandLabel}
|
|
208
|
+
collapseLabel={collapseLabel}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
</CollapsibleContent>
|
|
212
|
+
</Collapsible>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function NavNodes({
|
|
217
|
+
nodes,
|
|
218
|
+
pathname,
|
|
219
|
+
depth,
|
|
220
|
+
drilldown,
|
|
221
|
+
expandLabel,
|
|
222
|
+
collapseLabel,
|
|
223
|
+
}: {
|
|
224
|
+
nodes: NavNode[];
|
|
225
|
+
pathname: string;
|
|
226
|
+
depth: number;
|
|
227
|
+
drilldown?: boolean;
|
|
228
|
+
expandLabel: string;
|
|
229
|
+
collapseLabel: string;
|
|
230
|
+
}) {
|
|
231
|
+
const rendered: ReactNode[] = [];
|
|
232
|
+
|
|
233
|
+
nodes.forEach(node => {
|
|
234
|
+
if (isNodeHidden(node)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (node.kind === 'link') {
|
|
239
|
+
rendered.push(
|
|
240
|
+
<a
|
|
241
|
+
key={`link-${node.href}`}
|
|
242
|
+
href={node.href}
|
|
243
|
+
target={node.target}
|
|
244
|
+
rel={node.target === '_blank' ? 'noreferrer' : undefined}
|
|
245
|
+
className={classNames(
|
|
246
|
+
'flex min-w-0 items-center gap-[0.4rem] border-sidebar-border border-l px-3 py-[0.55rem] text-muted-foreground hover:text-sidebar-accent-foreground [overflow-wrap:anywhere]',
|
|
247
|
+
{ 'pl-6': depth > 1 },
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
{resolveIcon(node.icon)}
|
|
251
|
+
{node.label}
|
|
252
|
+
<ExternalLink size={12} className="ml-auto opacity-60" />
|
|
253
|
+
</a>,
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (node.kind === 'page') {
|
|
259
|
+
const { url, label, icon, tag } = node.page;
|
|
260
|
+
const isSelected = url === pathname;
|
|
261
|
+
|
|
262
|
+
rendered.push(
|
|
263
|
+
<Link
|
|
264
|
+
key={url}
|
|
265
|
+
to={url}
|
|
266
|
+
className={classNames(
|
|
267
|
+
'flex min-w-0 items-center gap-[0.4rem] border-sidebar-border border-l px-3 py-[0.55rem] [overflow-wrap:anywhere]',
|
|
268
|
+
{
|
|
269
|
+
'pl-6': depth > 1,
|
|
270
|
+
[selectedClass]: isSelected,
|
|
271
|
+
'text-muted-foreground hover:text-sidebar-accent-foreground': !isSelected,
|
|
272
|
+
},
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
{resolveIcon(icon)}
|
|
276
|
+
{label}
|
|
277
|
+
{tag ? (
|
|
278
|
+
<Badge
|
|
279
|
+
variant="secondary"
|
|
280
|
+
className="ml-auto h-auto rounded-sm px-[0.35rem] py-[0.05rem] text-[0.7rem] text-muted-foreground uppercase"
|
|
281
|
+
>
|
|
282
|
+
{tag}
|
|
283
|
+
</Badge>
|
|
284
|
+
) : null}
|
|
285
|
+
</Link>,
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
rendered.push(
|
|
291
|
+
<CollapsibleGroup
|
|
292
|
+
key={`group-${node.label}`}
|
|
293
|
+
node={node}
|
|
294
|
+
pathname={pathname}
|
|
295
|
+
depth={depth}
|
|
296
|
+
drilldown={drilldown}
|
|
297
|
+
expandLabel={expandLabel}
|
|
298
|
+
collapseLabel={collapseLabel}
|
|
299
|
+
/>,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return <>{rendered}</>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function SideNav({
|
|
307
|
+
tabs,
|
|
308
|
+
navigation,
|
|
309
|
+
anchors,
|
|
310
|
+
isSticky,
|
|
311
|
+
drilldown,
|
|
312
|
+
navigationLabel,
|
|
313
|
+
expandLabel,
|
|
314
|
+
collapseLabel,
|
|
315
|
+
}: SideNavProps) {
|
|
316
|
+
const { pathname } = useLocation();
|
|
317
|
+
|
|
318
|
+
const tab = [...tabs]
|
|
319
|
+
.sort((a, b) => b.url.length - a.url.length)
|
|
320
|
+
.find(item => pathname === item.url || pathname.startsWith(`${item.url}/`));
|
|
321
|
+
const nodes = navigation[tab?.id || tabs?.[0]?.id] || [];
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<ScrollArea className={classNames('w-full max-w-full', { 'h-full': isSticky })}>
|
|
325
|
+
<nav className="flex w-full flex-col gap-6 pr-4 text-sm" aria-label={navigationLabel}>
|
|
326
|
+
{anchors.length ? (
|
|
327
|
+
<NavNodes
|
|
328
|
+
nodes={anchors}
|
|
329
|
+
pathname={pathname}
|
|
330
|
+
depth={0}
|
|
331
|
+
drilldown={drilldown}
|
|
332
|
+
expandLabel={expandLabel}
|
|
333
|
+
collapseLabel={collapseLabel}
|
|
334
|
+
/>
|
|
335
|
+
) : null}
|
|
336
|
+
<NavNodes
|
|
337
|
+
nodes={nodes}
|
|
338
|
+
pathname={pathname}
|
|
339
|
+
depth={0}
|
|
340
|
+
drilldown={drilldown}
|
|
341
|
+
expandLabel={expandLabel}
|
|
342
|
+
collapseLabel={collapseLabel}
|
|
343
|
+
/>
|
|
344
|
+
</nav>
|
|
345
|
+
</ScrollArea>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Globe, Podcast } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
siBluesky,
|
|
4
|
+
siDiscord,
|
|
5
|
+
siFacebook,
|
|
6
|
+
siGithub,
|
|
7
|
+
siInstagram,
|
|
8
|
+
siMedium,
|
|
9
|
+
siReddit,
|
|
10
|
+
siTelegram,
|
|
11
|
+
siThreads,
|
|
12
|
+
siX,
|
|
13
|
+
siYcombinator,
|
|
14
|
+
siYoutube,
|
|
15
|
+
} from 'simple-icons';
|
|
16
|
+
|
|
17
|
+
interface BrandIcon {
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Slack and LinkedIn were removed from both simple-icons and lucide for
|
|
23
|
+
* trademark reasons, so their (widely mirrored) 24x24 paths live here.
|
|
24
|
+
*/
|
|
25
|
+
const LOCAL_BRANDS: Record<string, BrandIcon> = {
|
|
26
|
+
slack: {
|
|
27
|
+
path: 'M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z',
|
|
28
|
+
},
|
|
29
|
+
linkedin: {
|
|
30
|
+
path: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Icons for every platform name the footer `socials` config accepts. Brand
|
|
36
|
+
* marks come from simple-icons; generic ones (website, podcast) fall back
|
|
37
|
+
* to lucide.
|
|
38
|
+
*/
|
|
39
|
+
const BRAND_ICONS: Record<string, BrandIcon> = {
|
|
40
|
+
x: siX,
|
|
41
|
+
twitter: siX,
|
|
42
|
+
'x-twitter': siX,
|
|
43
|
+
github: siGithub,
|
|
44
|
+
facebook: siFacebook,
|
|
45
|
+
youtube: siYoutube,
|
|
46
|
+
discord: siDiscord,
|
|
47
|
+
instagram: siInstagram,
|
|
48
|
+
'hacker-news': siYcombinator,
|
|
49
|
+
medium: siMedium,
|
|
50
|
+
telegram: siTelegram,
|
|
51
|
+
bluesky: siBluesky,
|
|
52
|
+
threads: siThreads,
|
|
53
|
+
reddit: siReddit,
|
|
54
|
+
...LOCAL_BRANDS,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const LUCIDE_ICONS = {
|
|
58
|
+
website: Globe,
|
|
59
|
+
'earth-americas': Globe,
|
|
60
|
+
podcast: Podcast,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
export function isKnownPlatform(platform: string): boolean {
|
|
64
|
+
return platform in BRAND_ICONS || platform in LUCIDE_ICONS;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function SocialIcon({ platform, size = 18 }: { platform: string; size?: number }) {
|
|
68
|
+
const brand = BRAND_ICONS[platform];
|
|
69
|
+
|
|
70
|
+
if (brand) {
|
|
71
|
+
return (
|
|
72
|
+
<svg
|
|
73
|
+
role="img"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
viewBox="0 0 24 24"
|
|
76
|
+
width={size}
|
|
77
|
+
height={size}
|
|
78
|
+
fill="currentColor"
|
|
79
|
+
>
|
|
80
|
+
<path d={brand.path} />
|
|
81
|
+
</svg>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const Lucide = LUCIDE_ICONS[platform as keyof typeof LUCIDE_ICONS] || Globe;
|
|
86
|
+
|
|
87
|
+
return <Lucide size={size} aria-hidden="true" />;
|
|
88
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Moon, Sun } from '@/components/icons';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Both icons are always rendered and toggled via CSS on [data-theme], so the
|
|
6
|
+
* server render matches the client regardless of the user's stored theme.
|
|
7
|
+
*/
|
|
8
|
+
export function ThemeToggle({ label }: { label: string }) {
|
|
9
|
+
const handleClick = () => {
|
|
10
|
+
const current = document.documentElement.getAttribute('data-theme');
|
|
11
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
12
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
localStorage.setItem('shiso-theme', next);
|
|
16
|
+
} catch {
|
|
17
|
+
// Ignore storage failures (private mode, etc).
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Button
|
|
23
|
+
type="button"
|
|
24
|
+
variant="ghost"
|
|
25
|
+
size="icon"
|
|
26
|
+
className="inline-flex size-8 items-center justify-center rounded-md text-foreground hover:bg-accent"
|
|
27
|
+
onClick={handleClick}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
>
|
|
30
|
+
<Sun className="size-3.5 dark:hidden" />
|
|
31
|
+
<Moon className="hidden size-3.5 dark:block" />
|
|
32
|
+
</Button>
|
|
33
|
+
);
|
|
34
|
+
}
|