@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +896 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +74 -83
  7. package/scripts/check-package.mjs +54 -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 +298 -0
  11. package/scripts/lib/mdast.mjs +23 -0
  12. package/scripts/lib/slug.mjs +21 -0
  13. package/scripts/load-docs-config.mjs +244 -0
  14. package/scripts/prerender.mjs +194 -0
  15. package/scripts/validate-config.mjs +104 -0
  16. package/scripts/vite-docs-config.mjs +60 -0
  17. package/src/App.tsx +38 -0
  18. package/src/components/Banner.tsx +69 -0
  19. package/src/components/CodeBlock.tsx +46 -0
  20. package/src/components/ConfiguredIcon.tsx +15 -0
  21. package/src/components/ContextualMenu.tsx +93 -0
  22. package/src/components/DocContent.tsx +105 -0
  23. package/src/components/Docs.tsx +134 -0
  24. package/src/components/Footer.tsx +81 -0
  25. package/src/components/Header.tsx +82 -0
  26. package/src/components/LanguageSwitcher.tsx +59 -0
  27. package/src/components/Layout.tsx +24 -0
  28. package/src/components/PageLinks.tsx +71 -0
  29. package/src/components/Search.tsx +217 -0
  30. package/src/components/SideNav.tsx +347 -0
  31. package/src/components/SocialIcon.tsx +88 -0
  32. package/src/components/ThemeToggle.tsx +34 -0
  33. package/src/components/TopNav.tsx +127 -0
  34. package/src/components/VersionSwitcher.tsx +60 -0
  35. package/src/components/docs/Accordion.tsx +68 -0
  36. package/src/components/docs/Badge.tsx +171 -0
  37. package/src/components/docs/Callout.tsx +73 -0
  38. package/src/components/docs/Card.tsx +158 -0
  39. package/src/components/docs/CodeGroup.tsx +73 -0
  40. package/src/components/docs/Columns.tsx +20 -0
  41. package/src/components/docs/Expandable.tsx +28 -0
  42. package/src/components/docs/Frame.tsx +56 -0
  43. package/src/components/docs/Icon.tsx +30 -0
  44. package/src/components/docs/ParamField.tsx +45 -0
  45. package/src/components/docs/PropertiesTable.tsx +84 -0
  46. package/src/components/docs/ResponseField.tsx +36 -0
  47. package/src/components/docs/Steps.tsx +47 -0
  48. package/src/components/docs/Tabs.tsx +116 -0
  49. package/src/components/docs/Tooltip.tsx +21 -0
  50. package/src/components/docs/index.ts +15 -0
  51. package/src/components/docs/styles.ts +82 -0
  52. package/src/components/docs/utils.ts +118 -0
  53. package/src/components/icons/index.ts +17 -0
  54. package/src/components/ui/accordion.tsx +69 -0
  55. package/src/components/ui/alert.tsx +69 -0
  56. package/src/components/ui/badge.tsx +49 -0
  57. package/src/components/ui/button.tsx +58 -0
  58. package/src/components/ui/card.tsx +88 -0
  59. package/src/components/ui/collapsible.tsx +15 -0
  60. package/src/components/ui/command.tsx +173 -0
  61. package/src/components/ui/dialog.tsx +137 -0
  62. package/src/components/ui/dropdown-menu.tsx +257 -0
  63. package/src/components/ui/scroll-area.tsx +71 -0
  64. package/src/components/ui/sheet.tsx +124 -0
  65. package/src/components/ui/tabs.tsx +73 -0
  66. package/src/components/ui/tooltip.tsx +52 -0
  67. package/src/declarations.d.ts +9 -0
  68. package/src/entry-client.tsx +17 -0
  69. package/src/entry-server.tsx +89 -0
  70. package/src/generated/last-modified.ts +2 -0
  71. package/src/lib/content.ts +44 -0
  72. package/src/lib/docs-config.ts +986 -0
  73. package/src/lib/head.ts +231 -0
  74. package/src/lib/icon-registry.generated.ts +4 -0
  75. package/src/lib/icons.ts +29 -0
  76. package/src/lib/inline-markdown.tsx +86 -0
  77. package/src/lib/locale.ts +39 -0
  78. package/src/lib/mdast.ts +56 -0
  79. package/src/lib/paths.ts +86 -0
  80. package/src/lib/remark-toc.ts +71 -0
  81. package/src/lib/search/config.ts +43 -0
  82. package/src/lib/search/provider.ts +85 -0
  83. package/src/lib/search/providers/local.ts +15 -0
  84. package/src/lib/search-index.generated.ts +4 -0
  85. package/src/lib/search.ts +128 -0
  86. package/src/lib/site-config.ts +117 -0
  87. package/src/lib/site-model.ts +221 -0
  88. package/src/lib/slug.ts +38 -0
  89. package/src/lib/types.ts +515 -0
  90. package/src/lib/utils.ts +6 -0
  91. package/src/pages/DocPage.tsx +33 -0
  92. package/src/styles/global.css +268 -0
  93. package/src/styles/tokens.css +114 -0
  94. package/types/client.d.ts +3 -0
  95. package/types/search.d.ts +27 -0
  96. package/vite.config.ts +342 -0
  97. package/LICENSE +0 -21
  98. package/dist/index.css +0 -189
  99. package/dist/index.d.ts +0 -57
  100. package/dist/index.js +0 -464
  101. package/dist/index.mjs +0 -437
  102. package/server/index.d.ts +0 -30
  103. package/server/index.js +0 -189
  104. 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
+ }