@karimsa/mdxserve 0.0.0-stage → 0.1.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,417 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { AnimatePresence, motion } from "framer-motion";
13
+ import Check from "lucide-react/dist/esm/icons/check.mjs";
14
+ import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.mjs";
15
+ import { z } from "zod";
16
+ import { CrossFade } from "../CrossFade";
17
+ import { TRANSITIONS, VARIANTS } from "../motion";
18
+ import { Icon } from "../ui/Icon";
19
+
20
+ const dropdownOption = z.union([
21
+ z.string().describe("Option value and label, both the same string."),
22
+ z.object({
23
+ value: z.string().describe("Value reported for this option."),
24
+ label: z.string().describe("Text shown for this option."),
25
+ description: z
26
+ .string()
27
+ .optional()
28
+ .describe("One short line shown under the label in the menu."),
29
+ }),
30
+ ]);
31
+
32
+ export const dropdownProps = z.object({
33
+ label: z.string().optional().describe("Label shown above the control."),
34
+ icon: z
35
+ .string()
36
+ .optional()
37
+ .describe(
38
+ "Lucide icon name; renders an icon-only trigger instead of the labelled control. `label` becomes the accessible name.",
39
+ ),
40
+ options: z
41
+ .array(dropdownOption)
42
+ .optional()
43
+ .describe(
44
+ "Strings, or { value, label, description? } objects, for a plain picker. Omit this and use `Option` children instead for a content-switching dropdown.",
45
+ ),
46
+ value: z
47
+ .string()
48
+ .optional()
49
+ .describe("Selected value (controlled) — requires `onChange` to update it."),
50
+ defaultValue: z
51
+ .string()
52
+ .optional()
53
+ .describe("Initially selected value (uncontrolled). Defaults to the first option."),
54
+ placeholder: z
55
+ .string()
56
+ .optional()
57
+ .describe("Text shown on the trigger while nothing is selected."),
58
+ size: z.enum(["sm", "md"]).default("md").describe("Control height: sm (28px) or md (34px)."),
59
+ disabled: z.boolean().default(false).describe("Disables the control."),
60
+ onChange: z
61
+ .custom<(value: string) => void>()
62
+ .optional()
63
+ .describe("Called with the new value whenever the selection changes (JSX only)."),
64
+ children: z
65
+ .custom<ReactNode>()
66
+ .optional()
67
+ .describe(
68
+ "One or more `<Option>` elements. When present, `Dropdown` acts as a content switcher: the menu picks which option's content renders below, cross-faded.",
69
+ ),
70
+ });
71
+
72
+ export type DropdownProps = z.input<typeof dropdownProps>;
73
+
74
+ export const optionProps = z.object({
75
+ label: z.string().describe("Option label shown in the menu."),
76
+ value: z
77
+ .string()
78
+ .optional()
79
+ .describe("Stable identifier for this option; defaults to a slug of `label`."),
80
+ description: z.string().optional().describe("One short line shown under the label in the menu."),
81
+ children: z
82
+ .custom<ReactNode>()
83
+ .describe("Panel content shown when this option is selected; any MDX/Markdown content."),
84
+ });
85
+
86
+ export type OptionProps = z.infer<typeof optionProps>;
87
+
88
+ /**
89
+ * Renders nothing on its own — `Dropdown` reads each `Option` child's props
90
+ * to build the menu and panels, matching on `props.label` rather than
91
+ * `type === Option` (component identity changes under Fast Refresh; see
92
+ * client/builtins/Tabs.tsx).
93
+ */
94
+ export function Option(_props: OptionProps) {
95
+ return null;
96
+ }
97
+
98
+ function slugify(label: string): string {
99
+ return label
100
+ .toLowerCase()
101
+ .trim()
102
+ .replace(/[^a-z0-9]+/g, "-")
103
+ .replace(/(^-|-$)/g, "");
104
+ }
105
+
106
+ interface Entry {
107
+ value: string;
108
+ label: string;
109
+ description?: string;
110
+ content?: ReactNode;
111
+ }
112
+
113
+ type OptionLikeElement = ReactElement<{
114
+ label: string;
115
+ value?: string;
116
+ description?: string;
117
+ children?: ReactNode;
118
+ }>;
119
+
120
+ function collectOptions(children: ReactNode): Entry[] {
121
+ return Children.toArray(children)
122
+ .filter(
123
+ (child): child is OptionLikeElement =>
124
+ isValidElement(child) && typeof (child.props as { label?: unknown }).label === "string",
125
+ )
126
+ .map((child) => {
127
+ const { label, value, description, children: content } = child.props;
128
+ return { value: value ?? slugify(label), label, description, content };
129
+ });
130
+ }
131
+
132
+ /* Trigger height and the text size shared by the trigger and the menu rows. */
133
+ const SIZE_CLASSES: Record<
134
+ NonNullable<DropdownProps["size"]>,
135
+ { trigger: string; text: string }
136
+ > = {
137
+ sm: { trigger: "h-7 pl-2 pr-1.5", text: "text-[length:var(--size-sm)]" },
138
+ md: { trigger: "h-[34px] pl-2.5 pr-2", text: "text-[length:var(--size-md)]" },
139
+ };
140
+
141
+ /**
142
+ * A menu-style picker: a button that opens a popover listbox, styled like the
143
+ * rest of the chrome (card surface, hairline border, teal selection) and
144
+ * animated with the design's `pop` entrance. Fully keyboard operable
145
+ * (Enter/Space/arrows open, arrows/Home/End move, Enter picks, Esc closes).
146
+ */
147
+ export default function Dropdown({
148
+ label,
149
+ icon,
150
+ options,
151
+ value,
152
+ defaultValue,
153
+ placeholder = "Choose one",
154
+ size = "md",
155
+ disabled = false,
156
+ onChange,
157
+ children,
158
+ }: DropdownProps) {
159
+ const switcherEntries = collectOptions(children);
160
+ const isSwitcher = switcherEntries.length > 0;
161
+ const entries: Entry[] = isSwitcher
162
+ ? switcherEntries
163
+ : (options ?? []).map((option) =>
164
+ typeof option === "string" ? { value: option, label: option } : option,
165
+ );
166
+
167
+ const controlled = value !== undefined;
168
+ const [internal, setInternal] = useState<string | undefined>(
169
+ defaultValue ?? (isSwitcher ? entries[0]?.value : undefined),
170
+ );
171
+ const current = controlled ? value : internal;
172
+ const selected = entries.find((entry) => entry.value === current);
173
+
174
+ const [open, setOpen] = useState(false);
175
+ const [highlighted, setHighlighted] = useState(0);
176
+ const rootRef = useRef<HTMLDivElement>(null);
177
+ const triggerRef = useRef<HTMLButtonElement>(null);
178
+ const listRef = useRef<HTMLUListElement>(null);
179
+ const id = useId();
180
+ const listboxId = `${id}-listbox`;
181
+ const panelId = `${id}-panel`;
182
+
183
+ function select(next: string) {
184
+ if (!controlled) setInternal(next);
185
+ onChange?.(next);
186
+ setOpen(false);
187
+ triggerRef.current?.focus();
188
+ }
189
+
190
+ function openMenu() {
191
+ if (disabled || entries.length === 0) return;
192
+ const idx = Math.max(
193
+ 0,
194
+ entries.findIndex((entry) => entry.value === current),
195
+ );
196
+ setHighlighted(idx);
197
+ setOpen(true);
198
+ }
199
+
200
+ // Close on outside click / focus leaving the component.
201
+ useEffect(() => {
202
+ if (!open) return;
203
+ const onPointerDown = (event: PointerEvent) => {
204
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
205
+ };
206
+ document.addEventListener("pointerdown", onPointerDown);
207
+ return () => document.removeEventListener("pointerdown", onPointerDown);
208
+ }, [open]);
209
+
210
+ // Keep the highlighted row in view while arrowing through a long list.
211
+ useEffect(() => {
212
+ if (!open) return;
213
+ const row = listRef.current?.children[highlighted] as HTMLElement | undefined;
214
+ row?.scrollIntoView({ block: "nearest" });
215
+ }, [open, highlighted]);
216
+
217
+ function onKeyDown(event: KeyboardEvent<HTMLElement>) {
218
+ if (disabled) return;
219
+ const last = entries.length - 1;
220
+ switch (event.key) {
221
+ case "ArrowDown":
222
+ event.preventDefault();
223
+ if (!open) openMenu();
224
+ else setHighlighted((index) => Math.min(last, index + 1));
225
+ break;
226
+ case "ArrowUp":
227
+ event.preventDefault();
228
+ if (!open) openMenu();
229
+ else setHighlighted((index) => Math.max(0, index - 1));
230
+ break;
231
+ case "Home":
232
+ if (open) {
233
+ event.preventDefault();
234
+ setHighlighted(0);
235
+ }
236
+ break;
237
+ case "End":
238
+ if (open) {
239
+ event.preventDefault();
240
+ setHighlighted(last);
241
+ }
242
+ break;
243
+ case "Enter":
244
+ case " ":
245
+ event.preventDefault();
246
+ if (!open) openMenu();
247
+ else if (entries[highlighted]) select(entries[highlighted].value);
248
+ break;
249
+ case "Escape":
250
+ if (open) {
251
+ event.preventDefault();
252
+ setOpen(false);
253
+ triggerRef.current?.focus();
254
+ }
255
+ break;
256
+ case "Tab":
257
+ setOpen(false);
258
+ break;
259
+ }
260
+ }
261
+
262
+ const control = (
263
+ <div
264
+ ref={rootRef}
265
+ className={
266
+ icon
267
+ ? "not-prose relative inline-flex"
268
+ : "not-prose relative inline-flex w-full max-w-xs flex-col gap-1.5"
269
+ }
270
+ onKeyDown={onKeyDown}
271
+ >
272
+ {label && !icon ? (
273
+ <span
274
+ id={`${id}-label`}
275
+ className="font-sans text-[length:var(--size-sm)] font-semibold leading-normal text-text-heading"
276
+ >
277
+ {label}
278
+ </span>
279
+ ) : null}
280
+ {icon ? (
281
+ <button
282
+ ref={triggerRef}
283
+ type="button"
284
+ role="combobox"
285
+ aria-haspopup="listbox"
286
+ aria-expanded={open}
287
+ aria-controls={listboxId}
288
+ disabled={disabled}
289
+ onClick={() => (open ? setOpen(false) : openMenu())}
290
+ aria-label={`${label ?? "Choose"}: ${selected ? selected.label : placeholder}`}
291
+ title={`${label ?? "Choose"}: ${selected ? selected.label : placeholder}`}
292
+ className={[
293
+ "inline-flex h-[26px] w-[26px] shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent transition-colors",
294
+ "disabled:cursor-not-allowed disabled:opacity-45",
295
+ open ? "bg-surface-active text-text-heading" : "text-text-muted hover:bg-surface-hover",
296
+ ].join(" ")}
297
+ >
298
+ <Icon name={icon} size="sm" />
299
+ </button>
300
+ ) : (
301
+ <button
302
+ ref={triggerRef}
303
+ type="button"
304
+ role="combobox"
305
+ aria-haspopup="listbox"
306
+ aria-expanded={open}
307
+ aria-controls={listboxId}
308
+ aria-labelledby={label ? `${id}-label` : undefined}
309
+ disabled={disabled}
310
+ onClick={() => (open ? setOpen(false) : openMenu())}
311
+ className={[
312
+ "flex w-full cursor-pointer items-center gap-2 rounded-md border bg-surface-card text-left shadow-xs",
313
+ "font-sans font-normal leading-normal text-text-body transition-colors duration-150",
314
+ open
315
+ ? "border-border-accent"
316
+ : "border-border-default hover:border-border-strong hover:bg-surface-hover",
317
+ "disabled:cursor-not-allowed disabled:opacity-45",
318
+ SIZE_CLASSES[size].trigger,
319
+ SIZE_CLASSES[size].text,
320
+ ].join(" ")}
321
+ >
322
+ <span className={`min-w-0 flex-1 truncate ${selected ? "" : "text-text-subtle"}`}>
323
+ {selected ? selected.label : placeholder}
324
+ </span>
325
+ <motion.span
326
+ animate={{ rotate: open ? 180 : 0 }}
327
+ transition={TRANSITIONS.snap}
328
+ className="inline-flex text-text-subtle"
329
+ >
330
+ <ChevronDown aria-hidden="true" size={14} strokeWidth={1.75} />
331
+ </motion.span>
332
+ </button>
333
+ )}
334
+
335
+ <AnimatePresence>
336
+ {open ? (
337
+ <motion.ul
338
+ ref={listRef}
339
+ id={listboxId}
340
+ role="listbox"
341
+ aria-activedescendant={entries[highlighted] ? `${id}-opt-${highlighted}` : undefined}
342
+ {...VARIANTS.pop}
343
+ style={{ zIndex: "var(--z-dropdown)" }}
344
+ className={
345
+ icon
346
+ ? "absolute right-0 top-full mt-1.5 max-h-72 min-w-[12rem] overflow-y-auto rounded-lg border border-border-default bg-surface-raised p-1 shadow-md"
347
+ : "absolute left-0 top-full mt-1.5 max-h-72 w-full min-w-[12rem] overflow-y-auto rounded-lg border border-border-default bg-surface-raised p-1 shadow-md"
348
+ }
349
+ >
350
+ {entries.map((entry, index) => {
351
+ const isSelected = entry.value === current;
352
+ const isActive = index === highlighted;
353
+ return (
354
+ <li
355
+ key={entry.value}
356
+ id={`${id}-opt-${index}`}
357
+ role="option"
358
+ aria-selected={isSelected}
359
+ onPointerEnter={() => setHighlighted(index)}
360
+ onClick={() => select(entry.value)}
361
+ className={`relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "text-text-heading" : ""}`}
362
+ >
363
+ {isActive ? (
364
+ <motion.span
365
+ layoutId={`${id}-highlight`}
366
+ transition={TRANSITIONS.snap}
367
+ className="absolute inset-0 rounded-md bg-surface-hover ring-1 ring-inset ring-border-default"
368
+ aria-hidden="true"
369
+ />
370
+ ) : null}
371
+ <span className="relative flex min-w-0 flex-1 flex-col">
372
+ <span
373
+ className={`truncate font-sans leading-normal ${SIZE_CLASSES[size].text} ${isSelected ? "font-semibold text-text-accent" : "font-medium text-text-body"}`}
374
+ >
375
+ {entry.label}
376
+ </span>
377
+ {entry.description ? (
378
+ <span className="truncate font-sans text-[length:var(--size-xs)] font-normal leading-snug text-text-muted">
379
+ {entry.description}
380
+ </span>
381
+ ) : null}
382
+ </span>
383
+ <span
384
+ className={`relative inline-flex w-3.5 shrink-0 justify-center text-text-accent ${isSelected ? "" : "invisible"}`}
385
+ >
386
+ <Check aria-hidden="true" size={14} strokeWidth={2} />
387
+ </span>
388
+ </li>
389
+ );
390
+ })}
391
+ </motion.ul>
392
+ ) : null}
393
+ </AnimatePresence>
394
+ </div>
395
+ );
396
+
397
+ if (!isSwitcher) return control;
398
+
399
+ const activeEntry = selected ?? entries[0];
400
+
401
+ return (
402
+ <div>
403
+ {control}
404
+ <div id={panelId} aria-live="polite" className="mt-4">
405
+ <CrossFade
406
+ active={activeEntry.value}
407
+ panes={entries.map((entry) => ({
408
+ key: entry.value,
409
+ node: (
410
+ <div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{entry.content}</div>
411
+ ),
412
+ }))}
413
+ />
414
+ </div>
415
+ </div>
416
+ );
417
+ }
@@ -0,0 +1,87 @@
1
+ import { useState } from "react";
2
+ import { z } from "zod";
3
+ import { Icon } from "../ui/Icon";
4
+
5
+ // `FileNode` is recursive, so it's declared with z.lazy() rather than inferred
6
+ // from a flat schema; MDX authors pass it as a plain JSON-serializable array
7
+ // (`nodes={[{ name: "src", children: [...] }]}`), no functions or components.
8
+ export interface FileNode {
9
+ name: string;
10
+ path?: string;
11
+ children?: FileNode[];
12
+ open?: boolean;
13
+ }
14
+
15
+ const fileNodeSchema: z.ZodType<FileNode> = z.lazy(() =>
16
+ z.object({
17
+ name: z.string().describe("File or folder name shown in the row."),
18
+ path: z.string().optional().describe("Route/identifier used to match `activePath`."),
19
+ children: z
20
+ .array(fileNodeSchema)
21
+ .optional()
22
+ .describe("Child nodes; presence makes this row a folder."),
23
+ open: z.boolean().optional().describe("Folder starts expanded unless this is `false`."),
24
+ }),
25
+ );
26
+
27
+ export const fileTreeProps = z.object({
28
+ nodes: z.array(fileNodeSchema).default([]).describe("Tree of files/folders to render."),
29
+ activePath: z.string().optional().describe("`path` of the row to highlight as active."),
30
+ });
31
+
32
+ export type FileTreeProps = z.infer<typeof fileTreeProps>;
33
+
34
+ function Row({ node, depth, activePath }: { node: FileNode; depth: number; activePath?: string }) {
35
+ const [open, setOpen] = useState(node.open !== false);
36
+ const isDir = Boolean(node.children);
37
+ const active = Boolean(activePath) && activePath === node.path;
38
+
39
+ return (
40
+ <>
41
+ <div
42
+ onClick={isDir ? () => setOpen((isOpen) => !isOpen) : undefined}
43
+ className={`flex h-[26px] items-center gap-2 rounded-sm pr-2 font-mono font-normal leading-[1.62] text-[length:var(--size-xs)] ${
44
+ isDir ? "cursor-pointer" : "cursor-default"
45
+ } ${active ? "bg-surface-accent-soft text-text-accent" : isDir ? "text-text-body" : "text-text-muted"}`}
46
+ style={{ paddingLeft: 8 + depth * 14 }}
47
+ >
48
+ {isDir ? (
49
+ <Icon
50
+ name={open ? "chevron-down" : "chevron-right"}
51
+ size={12}
52
+ className="text-text-subtle"
53
+ />
54
+ ) : (
55
+ <span className="w-3" />
56
+ )}
57
+ <Icon name={isDir ? "folder" : "file-text"} size={13} className="text-text-subtle" />
58
+ <span>{node.name}</span>
59
+ </div>
60
+ {isDir && open
61
+ ? node.children!.map((child, index) => (
62
+ <Row
63
+ key={child.path ?? `${child.name}-${index}`}
64
+ node={child}
65
+ depth={depth + 1}
66
+ activePath={activePath}
67
+ />
68
+ ))
69
+ : null}
70
+ </>
71
+ );
72
+ }
73
+
74
+ export default function FileTree({ nodes = [], activePath }: FileTreeProps) {
75
+ return (
76
+ <div className="not-prose rounded-lg border border-border-default bg-surface-card p-2">
77
+ {nodes.map((node, index) => (
78
+ <Row
79
+ key={node.path ?? `${node.name}-${index}`}
80
+ node={node}
81
+ depth={0}
82
+ activePath={activePath}
83
+ />
84
+ ))}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ import { z } from "zod";
3
+
4
+ export const kbdProps = z.object({
5
+ children: z
6
+ .custom<ReactNode>()
7
+ .describe("Key label, e.g. a letter, `esc`, or a unicode symbol such as `⌘`."),
8
+ });
9
+
10
+ export type KbdProps = z.infer<typeof kbdProps>;
11
+
12
+ export default function Kbd({ children }: KbdProps) {
13
+ return (
14
+ <kbd className="not-prose inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-b-2 border-border-default bg-surface-card px-1.5 font-mono leading-[1.62] text-[length:var(--size-2xs)] font-medium text-text-muted">
15
+ {children}
16
+ </kbd>
17
+ );
18
+ }