@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +835 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +73 -83
  7. package/scripts/check-package.mjs +18 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +252 -0
  11. package/scripts/load-docs-config.mjs +244 -0
  12. package/scripts/prerender.mjs +187 -0
  13. package/scripts/validate-config.mjs +104 -0
  14. package/scripts/vite-docs-config.mjs +60 -0
  15. package/src/App.tsx +39 -0
  16. package/src/components/Banner.tsx +69 -0
  17. package/src/components/CodeBlock.tsx +46 -0
  18. package/src/components/ConfiguredIcon.tsx +15 -0
  19. package/src/components/ContextualMenu.tsx +93 -0
  20. package/src/components/DocContent.tsx +100 -0
  21. package/src/components/Docs.tsx +126 -0
  22. package/src/components/Footer.tsx +84 -0
  23. package/src/components/Header.tsx +72 -0
  24. package/src/components/Layout.tsx +16 -0
  25. package/src/components/PageLinks.tsx +71 -0
  26. package/src/components/Search.tsx +208 -0
  27. package/src/components/SideNav.tsx +347 -0
  28. package/src/components/SocialIcon.tsx +88 -0
  29. package/src/components/ThemeToggle.tsx +34 -0
  30. package/src/components/TopNav.tsx +127 -0
  31. package/src/components/docs/Accordion.tsx +68 -0
  32. package/src/components/docs/Badge.tsx +171 -0
  33. package/src/components/docs/Callout.tsx +73 -0
  34. package/src/components/docs/Card.tsx +158 -0
  35. package/src/components/docs/CodeGroup.tsx +73 -0
  36. package/src/components/docs/Columns.tsx +20 -0
  37. package/src/components/docs/Expandable.tsx +28 -0
  38. package/src/components/docs/Frame.tsx +56 -0
  39. package/src/components/docs/Icon.tsx +30 -0
  40. package/src/components/docs/ParamField.tsx +45 -0
  41. package/src/components/docs/PropertiesTable.tsx +84 -0
  42. package/src/components/docs/ResponseField.tsx +36 -0
  43. package/src/components/docs/Steps.tsx +47 -0
  44. package/src/components/docs/Tabs.tsx +116 -0
  45. package/src/components/docs/Tooltip.tsx +21 -0
  46. package/src/components/docs/index.ts +15 -0
  47. package/src/components/docs/styles.ts +82 -0
  48. package/src/components/docs/utils.ts +118 -0
  49. package/src/components/icons/index.ts +17 -0
  50. package/src/components/ui/accordion.tsx +69 -0
  51. package/src/components/ui/alert.tsx +69 -0
  52. package/src/components/ui/badge.tsx +49 -0
  53. package/src/components/ui/button.tsx +58 -0
  54. package/src/components/ui/card.tsx +88 -0
  55. package/src/components/ui/collapsible.tsx +15 -0
  56. package/src/components/ui/command.tsx +173 -0
  57. package/src/components/ui/dialog.tsx +137 -0
  58. package/src/components/ui/dropdown-menu.tsx +257 -0
  59. package/src/components/ui/scroll-area.tsx +71 -0
  60. package/src/components/ui/sheet.tsx +124 -0
  61. package/src/components/ui/tabs.tsx +73 -0
  62. package/src/components/ui/tooltip.tsx +52 -0
  63. package/src/declarations.d.ts +9 -0
  64. package/src/entry-client.tsx +17 -0
  65. package/src/entry-server.tsx +77 -0
  66. package/src/generated/last-modified.ts +2 -0
  67. package/src/lib/content.ts +44 -0
  68. package/src/lib/docs-config.ts +682 -0
  69. package/src/lib/head.ts +220 -0
  70. package/src/lib/icon-registry.generated.ts +4 -0
  71. package/src/lib/icons.ts +29 -0
  72. package/src/lib/inline-markdown.tsx +86 -0
  73. package/src/lib/mdast.ts +56 -0
  74. package/src/lib/paths.ts +86 -0
  75. package/src/lib/remark-toc.ts +71 -0
  76. package/src/lib/search/config.ts +43 -0
  77. package/src/lib/search/provider.ts +80 -0
  78. package/src/lib/search/providers/local.ts +15 -0
  79. package/src/lib/search-index.generated.ts +4 -0
  80. package/src/lib/search.ts +100 -0
  81. package/src/lib/site-config.ts +104 -0
  82. package/src/lib/site-model.ts +221 -0
  83. package/src/lib/slug.ts +38 -0
  84. package/src/lib/types.ts +478 -0
  85. package/src/lib/utils.ts +6 -0
  86. package/src/pages/DocPage.tsx +33 -0
  87. package/src/styles/global.css +268 -0
  88. package/src/styles/tokens.css +114 -0
  89. package/types/client.d.ts +3 -0
  90. package/types/search.d.ts +17 -0
  91. package/vite.config.ts +342 -0
  92. package/LICENSE +0 -21
  93. package/dist/index.css +0 -189
  94. package/dist/index.d.ts +0 -57
  95. package/dist/index.js +0 -464
  96. package/dist/index.mjs +0 -437
  97. package/server/index.d.ts +0 -30
  98. package/server/index.js +0 -189
  99. package/styles.css +0 -4766
@@ -0,0 +1,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
+ }
@@ -0,0 +1,127 @@
1
+ import classNames from 'classnames';
2
+ import { Link, useLocation, useNavigate } from 'react-router';
3
+ import { ConfiguredIcon } from '@/components/ConfiguredIcon';
4
+ import { ChevronRight } from '@/components/icons';
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from '@/components/ui/dropdown-menu';
11
+ import type { DocsTab, LinkTarget, NavNode, NormalizedDocsConfig } from '@/lib/types';
12
+
13
+ interface MenuLink {
14
+ label: string;
15
+ href: string;
16
+ target: LinkTarget;
17
+ routed: boolean;
18
+ }
19
+
20
+ function collectMenuLinks(nodes: NavNode[]): MenuLink[] {
21
+ const links: MenuLink[] = [];
22
+
23
+ for (const node of nodes) {
24
+ if (node.kind === 'page') {
25
+ if (!node.page.hidden) {
26
+ links.push({ label: node.page.label, href: node.page.url, target: '_self', routed: true });
27
+ }
28
+ } else if (node.kind === 'link') {
29
+ if (!node.hidden) {
30
+ links.push({ label: node.label, href: node.href, target: node.target, routed: false });
31
+ }
32
+ } else if (!node.hidden) {
33
+ if (node.root && !node.root.page.hidden) {
34
+ links.push({
35
+ label: node.root.page.label,
36
+ href: node.root.page.url,
37
+ target: '_self',
38
+ routed: true,
39
+ });
40
+ }
41
+ links.push(...collectMenuLinks(node.children));
42
+ }
43
+ }
44
+
45
+ return links;
46
+ }
47
+
48
+ export function TopNav({ docs, label }: { docs: NormalizedDocsConfig; label: string }) {
49
+ const { pathname } = useLocation();
50
+ const navigate = useNavigate();
51
+ const tabs = docs.tabs.filter(tab => !tab.hidden);
52
+
53
+ if (!tabs.length) {
54
+ return null;
55
+ }
56
+
57
+ const page = docs.pages.find(item => item.url === pathname);
58
+ const selected =
59
+ page?.tabId ||
60
+ [...tabs]
61
+ .sort((a, b) => b.url.length - a.url.length)
62
+ .find(tab => pathname === tab.url || pathname.startsWith(`${tab.url}/`))?.id ||
63
+ tabs[0]?.id;
64
+ const tabClass = (tab: DocsTab) =>
65
+ classNames(
66
+ 'flex h-full items-center gap-1 whitespace-nowrap border-transparent border-b-2 font-medium',
67
+ {
68
+ 'border-b-primary text-foreground': tab.id === selected,
69
+ 'text-muted-foreground hover:text-foreground': tab.id !== selected,
70
+ },
71
+ );
72
+
73
+ return (
74
+ <nav
75
+ className="hidden h-[calc(100%+1px)] max-w-screen self-start items-center gap-7 overflow-x-auto text-sm [scrollbar-width:none] lg:flex [&::-webkit-scrollbar]:hidden"
76
+ aria-label={label}
77
+ >
78
+ {tabs.map(tab => {
79
+ if (tab.presentation === 'dropdown') {
80
+ const menuLinks = collectMenuLinks(docs.navigation[tab.id] || []);
81
+
82
+ return (
83
+ <DropdownMenu key={tab.id}>
84
+ <DropdownMenuTrigger render={<button type="button" className={tabClass(tab)} />}>
85
+ <ConfiguredIcon icon={tab.icon} />
86
+ {tab.label}
87
+ <ChevronRight className="size-3.5 rotate-90" />
88
+ </DropdownMenuTrigger>
89
+ <DropdownMenuContent align="start" className="min-w-48">
90
+ {menuLinks.map(item => (
91
+ <DropdownMenuItem
92
+ key={item.href}
93
+ onClick={() => {
94
+ if (item.routed) {
95
+ navigate(item.href);
96
+ } else {
97
+ window.open(
98
+ item.href,
99
+ item.target,
100
+ item.target === '_blank' ? 'noreferrer' : undefined,
101
+ );
102
+ }
103
+ }}
104
+ >
105
+ {item.label}
106
+ </DropdownMenuItem>
107
+ ))}
108
+ </DropdownMenuContent>
109
+ </DropdownMenu>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <Link
115
+ key={tab.id}
116
+ to={tab.url}
117
+ className={tabClass(tab)}
118
+ aria-current={tab.id === selected ? 'page' : undefined}
119
+ >
120
+ <ConfiguredIcon icon={tab.icon} />
121
+ {tab.label}
122
+ </Link>
123
+ );
124
+ })}
125
+ </nav>
126
+ );
127
+ }
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ AccordionContent,
4
+ AccordionItem,
5
+ Accordion as AccordionPrimitive,
6
+ AccordionTrigger,
7
+ } from '@/components/ui/accordion';
8
+ import { styles } from './styles';
9
+ import { resolveIcon, toElementArray } from './utils';
10
+
11
+ export interface AccordionProps {
12
+ title?: ReactNode;
13
+ icon?: ReactNode | string;
14
+ defaultOpen?: boolean;
15
+ children?: ReactNode;
16
+ }
17
+
18
+ function AccordionSection({ value, title, icon, children }: AccordionProps & { value: string }) {
19
+ return (
20
+ <AccordionItem value={value} className={styles.accordionItem}>
21
+ <AccordionTrigger className={styles.accordionTrigger}>
22
+ <span className="flex min-w-0 items-center gap-2">
23
+ {resolveIcon(icon)}
24
+ {title}
25
+ </span>
26
+ </AccordionTrigger>
27
+ <AccordionContent className={styles.accordionContent}>{children}</AccordionContent>
28
+ </AccordionItem>
29
+ );
30
+ }
31
+
32
+ export function Accordion({ title, icon, defaultOpen, children }: AccordionProps) {
33
+ const value = 'accordion-item';
34
+
35
+ return (
36
+ <AccordionPrimitive defaultValue={defaultOpen ? [value] : []} className={styles.accordion}>
37
+ <AccordionSection value={value} title={title} icon={icon}>
38
+ {children}
39
+ </AccordionSection>
40
+ </AccordionPrimitive>
41
+ );
42
+ }
43
+
44
+ export interface AccordionGroupProps {
45
+ children?: ReactNode;
46
+ defaultOpen?: boolean;
47
+ }
48
+
49
+ export function AccordionGroup({ children, defaultOpen = false }: AccordionGroupProps) {
50
+ const items = toElementArray<AccordionProps>(children);
51
+
52
+ if (!items.length) {
53
+ return null;
54
+ }
55
+
56
+ const values = items.map((_, index) => `accordion-${index + 1}`);
57
+ const defaultValue = values.filter((_, index) => {
58
+ return items[index].props.defaultOpen ?? (defaultOpen && index === 0);
59
+ });
60
+
61
+ return (
62
+ <AccordionPrimitive multiple defaultValue={defaultValue} className={styles.accordionGroup}>
63
+ {items.map((child, index) => (
64
+ <AccordionSection key={child.key ?? values[index]} value={values[index]} {...child.props} />
65
+ ))}
66
+ </AccordionPrimitive>
67
+ );
68
+ }