@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.
- package/LICENSE +21 -0
- package/README.md +233 -2
- package/client/App.tsx +7 -0
- package/client/CodeBlock.tsx +395 -0
- package/client/CrossFade.tsx +72 -0
- package/client/DocContext.ts +14 -0
- package/client/DocView.tsx +107 -0
- package/client/ErrorBox.tsx +23 -0
- package/client/Heading.tsx +31 -0
- package/client/HomeEmptyState.tsx +101 -0
- package/client/HomeView.tsx +78 -0
- package/client/ListingView.tsx +663 -0
- package/client/MdSection.tsx +234 -0
- package/client/MdSectionEditor.tsx +233 -0
- package/client/Mermaid.tsx +435 -0
- package/client/RenderErrorBoundary.tsx +40 -0
- package/client/Table.tsx +14 -0
- package/client/TaskCheckbox.tsx +38 -0
- package/client/api.ts +138 -0
- package/client/app.css +372 -0
- package/client/builtins/Badge.tsx +109 -0
- package/client/builtins/Button.tsx +111 -0
- package/client/builtins/Callout.tsx +97 -0
- package/client/builtins/Card.tsx +111 -0
- package/client/builtins/Chart.tsx +875 -0
- package/client/builtins/Diff.tsx +722 -0
- package/client/builtins/Dropdown.tsx +417 -0
- package/client/builtins/FileTree.tsx +87 -0
- package/client/builtins/Kbd.tsx +18 -0
- package/client/builtins/Screenshot.tsx +209 -0
- package/client/builtins/Sparkline.tsx +63 -0
- package/client/builtins/Tabs.tsx +169 -0
- package/client/builtins/Tooltip.tsx +52 -0
- package/client/builtins/chart-data.ts +133 -0
- package/client/builtins/index.ts +167 -0
- package/client/design/base/editor.css +151 -0
- package/client/design/base/prose.css +143 -0
- package/client/design/base/reset.css +79 -0
- package/client/design/tokens/colors.css +188 -0
- package/client/design/tokens/elevation.css +42 -0
- package/client/design/tokens/fonts.css +6 -0
- package/client/design/tokens/motion.css +76 -0
- package/client/design/tokens/spacing.css +34 -0
- package/client/design/tokens/typography.css +56 -0
- package/client/doc-module-cache.ts +17 -0
- package/client/editor-link.ts +27 -0
- package/client/entry.tsx +51 -0
- package/client/export-doc.ts +80 -0
- package/client/export-save.ts +96 -0
- package/client/favicon.svg +1 -0
- package/client/file-system-access.d.ts +29 -0
- package/client/format.ts +17 -0
- package/client/hooks.ts +34 -0
- package/client/lucide-icons.d.ts +9 -0
- package/client/mdx-components-base.ts +32 -0
- package/client/mdx-components.ts +18 -0
- package/client/mermaid-chart.ts +109 -0
- package/client/mermaid-direction.ts +73 -0
- package/client/motion.ts +104 -0
- package/client/platform.ts +16 -0
- package/client/route-path.ts +15 -0
- package/client/router.ts +452 -0
- package/client/shell/AppShell.tsx +401 -0
- package/client/shell/Footer.tsx +33 -0
- package/client/shell/NotFoundView.tsx +22 -0
- package/client/shell/Sidebar.tsx +169 -0
- package/client/shell/StandaloneShell.tsx +65 -0
- package/client/shell/TocRail.tsx +53 -0
- package/client/shell/TopBar.tsx +117 -0
- package/client/shell/use-doc-width.ts +61 -0
- package/client/shell/useToc.ts +77 -0
- package/client/ssr-entry.tsx +22 -0
- package/client/standalone-entry.tsx +51 -0
- package/client/state.ts +90 -0
- package/client/theme.ts +65 -0
- package/client/ui/Breadcrumb.tsx +49 -0
- package/client/ui/ConfirmDeleteDialog.tsx +113 -0
- package/client/ui/ExpandModal.tsx +342 -0
- package/client/ui/Icon.tsx +114 -0
- package/client/ui/IconButton.tsx +63 -0
- package/client/ui/Kbd.tsx +17 -0
- package/client/ui/PageNav.tsx +77 -0
- package/client/ui/ResizeHandle.tsx +201 -0
- package/client/ui/SearchDialog.tsx +187 -0
- package/client/ui/Tag.tsx +44 -0
- package/client/ui/Toast.tsx +189 -0
- package/client/ui/TocList.tsx +71 -0
- package/client/ui/icon-set.ts +102 -0
- package/client/ui/toast-count.ts +28 -0
- package/dist/cli.js +5091 -0
- package/dist/registry.json +703 -0
- package/dist/render-worker.js +145 -0
- package/package.json +115 -5
- package/skills/mdxserve/SKILL.md +178 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { motion } from "framer-motion";
|
|
3
|
+
import { Icon } from "./Icon";
|
|
4
|
+
import { TRANSITIONS } from "../motion";
|
|
5
|
+
|
|
6
|
+
export interface PageNavLink {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
href: string;
|
|
9
|
+
/** Overrides the default "Previous" / "Next" eyebrow. */
|
|
10
|
+
eyebrow?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PageNavProps extends HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
prev?: PageNavLink;
|
|
15
|
+
next?: PageNavLink;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Side({ item, dir }: { item: PageNavLink; dir: "prev" | "next" }) {
|
|
19
|
+
const [hover, setHover] = useState(false);
|
|
20
|
+
const prev = dir === "prev";
|
|
21
|
+
return (
|
|
22
|
+
<motion.a
|
|
23
|
+
href={item.href}
|
|
24
|
+
onMouseEnter={() => setHover(true)}
|
|
25
|
+
onMouseLeave={() => setHover(false)}
|
|
26
|
+
animate={{ y: hover ? -2 : 0 }}
|
|
27
|
+
whileTap={{ y: 0, scale: 0.995 }}
|
|
28
|
+
transition={TRANSITIONS.snap}
|
|
29
|
+
className={
|
|
30
|
+
"flex min-w-0 flex-1 flex-col gap-1 rounded-lg border bg-surface-card px-4 py-3 no-underline transition-shadow " +
|
|
31
|
+
(prev ? "items-start text-left" : "items-end text-right") +
|
|
32
|
+
" " +
|
|
33
|
+
(hover ? "border-border-accent shadow-sm" : "border-border-default")
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
<span className="flex items-center gap-1 font-mono text-[length:var(--size-2xs)] font-semibold uppercase tracking-[var(--tracking-caps)] text-text-subtle">
|
|
37
|
+
{prev ? (
|
|
38
|
+
<motion.span
|
|
39
|
+
animate={{ x: hover ? -2 : 0 }}
|
|
40
|
+
transition={TRANSITIONS.snap}
|
|
41
|
+
className="inline-flex"
|
|
42
|
+
>
|
|
43
|
+
<Icon name="arrow-left" size={12} />
|
|
44
|
+
</motion.span>
|
|
45
|
+
) : null}
|
|
46
|
+
{item.eyebrow ?? (prev ? "Previous" : "Next")}
|
|
47
|
+
{prev ? null : (
|
|
48
|
+
<motion.span
|
|
49
|
+
animate={{ x: hover ? 2 : 0 }}
|
|
50
|
+
transition={TRANSITIONS.snap}
|
|
51
|
+
className="inline-flex"
|
|
52
|
+
>
|
|
53
|
+
<Icon name="arrow-right" size={12} />
|
|
54
|
+
</motion.span>
|
|
55
|
+
)}
|
|
56
|
+
</span>
|
|
57
|
+
<span
|
|
58
|
+
className={
|
|
59
|
+
"max-w-full break-words text-[15px] leading-normal font-semibold transition-colors " +
|
|
60
|
+
(hover ? "text-text-accent" : "text-text-heading")
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
{item.label}
|
|
64
|
+
</span>
|
|
65
|
+
</motion.a>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prev/next page cards below the prose column. */
|
|
70
|
+
export function PageNav({ prev, next, className, ...rest }: PageNavProps) {
|
|
71
|
+
return (
|
|
72
|
+
<div className={"flex gap-3" + (className ? " " + className : "")} {...rest}>
|
|
73
|
+
{prev ? <Side item={prev} dir="prev" /> : <span className="flex-1" />}
|
|
74
|
+
{next ? <Side item={next} dir="next" /> : <span className="flex-1" />}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { TRANSITIONS } from "../motion";
|
|
4
|
+
|
|
5
|
+
/** Gap between the content's edge and the resize handle's resting line. */
|
|
6
|
+
const HANDLE_INSET = 12;
|
|
7
|
+
/** Hover zone width: covers the inset gap plus room on either side of the line. */
|
|
8
|
+
const HANDLE_ZONE = 40;
|
|
9
|
+
/** Height of the resting pill. */
|
|
10
|
+
const PILL_HEIGHT = 40;
|
|
11
|
+
|
|
12
|
+
type HandleState = "idle" | "hover" | "drag";
|
|
13
|
+
|
|
14
|
+
const handleBar: Record<
|
|
15
|
+
HandleState,
|
|
16
|
+
{ width: number; height: string; opacity: number; backgroundColor: string }
|
|
17
|
+
> = {
|
|
18
|
+
// A short muted pill at rest, so the affordance is discoverable.
|
|
19
|
+
idle: {
|
|
20
|
+
width: 3,
|
|
21
|
+
height: `${PILL_HEIGHT}px`,
|
|
22
|
+
opacity: 0.6,
|
|
23
|
+
backgroundColor: "var(--border-default)",
|
|
24
|
+
},
|
|
25
|
+
// Full-height line on hover.
|
|
26
|
+
hover: { width: 2, height: "100%", opacity: 1, backgroundColor: "var(--border-default)" },
|
|
27
|
+
// Thicker and teal while dragging.
|
|
28
|
+
drag: { width: 4, height: "100%", opacity: 1, backgroundColor: "var(--surface-accent)" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resize handle just outside one edge of a content block. The hover zone is
|
|
33
|
+
* wide (it spans the inset gap) so it's easy to find; the bar itself rests as
|
|
34
|
+
* a short pill, grows to a full-height line on hover, and thickens + turns
|
|
35
|
+
* teal mid-drag. For a page-height content block the pill sticks to the
|
|
36
|
+
* viewport's vertical centre (rather than the block's, which is usually off
|
|
37
|
+
* screen) so it's always in view; anchored panels are already viewport-bound,
|
|
38
|
+
* so theirs is simply centred.
|
|
39
|
+
*
|
|
40
|
+
* Two modes:
|
|
41
|
+
* - Default: the content is centred in the area marked with
|
|
42
|
+
* `data-content-area`, so moving one edge by `d` changes the width by `2d` —
|
|
43
|
+
* computed from the content's centre rather than accumulated deltas so the
|
|
44
|
+
* drag can't drift. Widths are clamped to [minWidth, maxWidth] and to the
|
|
45
|
+
* content area so the block never runs under the surrounding chrome.
|
|
46
|
+
* - `anchored`: the panel is flush against one edge of the layout (the docked
|
|
47
|
+
* sidebar on the left with `side="right"`, the toc rail on the right with
|
|
48
|
+
* `side="left"`), so the dragged edge maps 1:1 to the width with no doubling
|
|
49
|
+
* or centring, and the width is only clamped to [minWidth, maxWidth].
|
|
50
|
+
*/
|
|
51
|
+
export function ResizeHandle({
|
|
52
|
+
side,
|
|
53
|
+
container,
|
|
54
|
+
onResize,
|
|
55
|
+
label,
|
|
56
|
+
minWidth,
|
|
57
|
+
maxWidth,
|
|
58
|
+
anchored = false,
|
|
59
|
+
}: {
|
|
60
|
+
side: "left" | "right";
|
|
61
|
+
container: React.RefObject<HTMLDivElement | null>;
|
|
62
|
+
onResize: (width: number) => void;
|
|
63
|
+
label: string;
|
|
64
|
+
minWidth: number;
|
|
65
|
+
maxWidth: number;
|
|
66
|
+
/** Panel flush against the layout edge opposite `side`, so the drag maps 1:1 to the width. */
|
|
67
|
+
anchored?: boolean;
|
|
68
|
+
}) {
|
|
69
|
+
const [hovered, setHovered] = useState(false);
|
|
70
|
+
const [dragging, setDragging] = useState(false);
|
|
71
|
+
const draggingRef = useRef(false);
|
|
72
|
+
/** Pointer distance from the resting bar at grab time, so a press anywhere
|
|
73
|
+
* in the zone drags relative to it instead of snapping the bar under the cursor. */
|
|
74
|
+
const grabOffset = useRef(0);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
return () => {
|
|
78
|
+
if (!draggingRef.current) return;
|
|
79
|
+
draggingRef.current = false;
|
|
80
|
+
document.body.style.cursor = "";
|
|
81
|
+
document.body.style.userSelect = "";
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const clamp = useCallback(
|
|
86
|
+
(px: number) => {
|
|
87
|
+
// Anchored panels aren't centred in a content area — clamp to the
|
|
88
|
+
// configured bounds only.
|
|
89
|
+
if (anchored) return Math.min(maxWidth, Math.max(minWidth, Math.round(px)));
|
|
90
|
+
const area = container.current?.closest("[data-content-area]");
|
|
91
|
+
const available = area instanceof HTMLElement ? area.clientWidth : maxWidth;
|
|
92
|
+
return Math.min(Math.min(maxWidth, available), Math.max(minWidth, Math.round(px)));
|
|
93
|
+
},
|
|
94
|
+
[container, minWidth, maxWidth, anchored],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const onPointerDown = useCallback(
|
|
98
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
if (anchored && container.current) {
|
|
101
|
+
// Pointer distance past the panel's dragged edge, so the drag stays
|
|
102
|
+
// relative to where it was grabbed instead of snapping the edge to the cursor.
|
|
103
|
+
const rect = container.current.getBoundingClientRect();
|
|
104
|
+
grabOffset.current =
|
|
105
|
+
side === "right" ? event.clientX - rect.right : rect.left - event.clientX;
|
|
106
|
+
} else if (container.current) {
|
|
107
|
+
const rect = container.current.getBoundingClientRect();
|
|
108
|
+
const centre = rect.left + rect.width / 2;
|
|
109
|
+
const half = side === "right" ? event.clientX - centre : centre - event.clientX;
|
|
110
|
+
grabOffset.current = half - (rect.width / 2 + HANDLE_INSET);
|
|
111
|
+
} else {
|
|
112
|
+
grabOffset.current = 0;
|
|
113
|
+
}
|
|
114
|
+
draggingRef.current = true;
|
|
115
|
+
setDragging(true);
|
|
116
|
+
try {
|
|
117
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
118
|
+
} catch {
|
|
119
|
+
// Not a live pointer (synthetic event); the drag still works while the cursor stays in the zone.
|
|
120
|
+
}
|
|
121
|
+
document.body.style.cursor = "col-resize";
|
|
122
|
+
document.body.style.userSelect = "none";
|
|
123
|
+
},
|
|
124
|
+
[container, side, anchored],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const onPointerMove = useCallback(
|
|
128
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
129
|
+
if (!draggingRef.current || !container.current) return;
|
|
130
|
+
if (anchored) {
|
|
131
|
+
const rect = container.current.getBoundingClientRect();
|
|
132
|
+
const edge = side === "right" ? event.clientX - rect.left : rect.right - event.clientX;
|
|
133
|
+
onResize(clamp(edge - grabOffset.current));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const rect = container.current.getBoundingClientRect();
|
|
137
|
+
const centre = rect.left + rect.width / 2;
|
|
138
|
+
const half = side === "right" ? event.clientX - centre : centre - event.clientX;
|
|
139
|
+
onResize(clamp((half - HANDLE_INSET - grabOffset.current) * 2));
|
|
140
|
+
},
|
|
141
|
+
[container, onResize, side, clamp, anchored],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
145
|
+
draggingRef.current = false;
|
|
146
|
+
setDragging(false);
|
|
147
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
148
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
149
|
+
}
|
|
150
|
+
document.body.style.cursor = "";
|
|
151
|
+
document.body.style.userSelect = "";
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const state: HandleState = dragging ? "drag" : hovered ? "hover" : "idle";
|
|
155
|
+
// Centre the bar on the inset line: the zone starts at the content edge and
|
|
156
|
+
// extends outward, so the line sits HANDLE_INSET in from the zone's inner edge.
|
|
157
|
+
// The left-anchored sidebar sits flush beside the main column, so rather than
|
|
158
|
+
// overhang into it the zone straddles the panel's border, centred on it. The
|
|
159
|
+
// right-anchored toc rail has a gutter on its left and its list draws its own
|
|
160
|
+
// left border, so its bar sits out in the gutter like the centred handles.
|
|
161
|
+
const zoneStyle =
|
|
162
|
+
anchored && side === "right"
|
|
163
|
+
? { right: -HANDLE_ZONE / 2 }
|
|
164
|
+
: side === "right"
|
|
165
|
+
? { right: -HANDLE_ZONE, paddingLeft: HANDLE_INSET }
|
|
166
|
+
: { left: -HANDLE_ZONE, paddingRight: HANDLE_INSET };
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
role="separator"
|
|
171
|
+
aria-orientation="vertical"
|
|
172
|
+
aria-label={label}
|
|
173
|
+
onPointerEnter={() => setHovered(true)}
|
|
174
|
+
onPointerLeave={() => setHovered(false)}
|
|
175
|
+
onPointerDown={onPointerDown}
|
|
176
|
+
onPointerMove={onPointerMove}
|
|
177
|
+
onPointerUp={onPointerUp}
|
|
178
|
+
onPointerCancel={onPointerUp}
|
|
179
|
+
style={{ width: HANDLE_ZONE, ...zoneStyle }}
|
|
180
|
+
className={
|
|
181
|
+
"absolute inset-y-0 z-10 flex cursor-col-resize " +
|
|
182
|
+
(anchored
|
|
183
|
+
? "items-center " + (side === "right" ? "justify-center" : "justify-end")
|
|
184
|
+
: // A column so the sticky pill can slide along the zone's full height.
|
|
185
|
+
"flex-col " + (side === "right" ? "items-start" : "items-end"))
|
|
186
|
+
}
|
|
187
|
+
>
|
|
188
|
+
<motion.div
|
|
189
|
+
initial={false}
|
|
190
|
+
animate={handleBar[state]}
|
|
191
|
+
transition={{
|
|
192
|
+
...TRANSITIONS.snap,
|
|
193
|
+
opacity: TRANSITIONS.fast,
|
|
194
|
+
backgroundColor: TRANSITIONS.fast,
|
|
195
|
+
}}
|
|
196
|
+
className={"shrink-0 rounded-full" + (anchored ? "" : " sticky")}
|
|
197
|
+
style={anchored ? undefined : { top: `calc(50vh - ${PILL_HEIGHT / 2}px)` }}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
type HTMLAttributes,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { AnimatePresence, motion, type MotionProps } from "framer-motion";
|
|
9
|
+
import { Icon } from "./Icon";
|
|
10
|
+
import { Kbd } from "./Kbd";
|
|
11
|
+
import { TRANSITIONS, VARIANTS } from "../motion";
|
|
12
|
+
|
|
13
|
+
export interface SearchResult {
|
|
14
|
+
title: ReactNode;
|
|
15
|
+
excerpt?: ReactNode;
|
|
16
|
+
path?: string;
|
|
17
|
+
/** Display path, e.g. "<rootName>/<relative path>"; shown above the title. */
|
|
18
|
+
label?: ReactNode;
|
|
19
|
+
/** Matched query terms; occurrences are highlighted in each field. */
|
|
20
|
+
terms?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeRegExp(input: string): string {
|
|
24
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Wrap every case-insensitive occurrence of `terms` in `text` with `<mark>`. */
|
|
28
|
+
function highlight(text: ReactNode, terms: string[] | undefined): ReactNode {
|
|
29
|
+
if (typeof text !== "string" || !terms || terms.length === 0) return text;
|
|
30
|
+
const pattern = terms
|
|
31
|
+
.filter((term) => term.length > 0)
|
|
32
|
+
.sort((first, second) => second.length - first.length)
|
|
33
|
+
.map(escapeRegExp)
|
|
34
|
+
.join("|");
|
|
35
|
+
if (pattern === "") return text;
|
|
36
|
+
const parts = text.split(new RegExp(`(${pattern})`, "gi"));
|
|
37
|
+
return parts.map((part, index) =>
|
|
38
|
+
index % 2 === 1 ? (
|
|
39
|
+
<mark key={index} className="rounded-sm bg-surface-accent-soft text-text-accent">
|
|
40
|
+
{part}
|
|
41
|
+
</mark>
|
|
42
|
+
) : (
|
|
43
|
+
part
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `results` shadows a legacy global HTML attribute (a WebKit `<input>` prop
|
|
49
|
+
// React still types on `HTMLAttributes`); `Omit` it, along with the
|
|
50
|
+
// motion.div-owned event props, before extending.
|
|
51
|
+
export interface SearchDialogProps extends Omit<
|
|
52
|
+
HTMLAttributes<HTMLDivElement>,
|
|
53
|
+
"results" | "onSelect" | keyof MotionProps
|
|
54
|
+
> {
|
|
55
|
+
open?: boolean;
|
|
56
|
+
query?: string;
|
|
57
|
+
results?: SearchResult[];
|
|
58
|
+
onQueryChange?: (query: string) => void;
|
|
59
|
+
onSelect?: (result: SearchResult) => void;
|
|
60
|
+
onClose?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const STAGGER_CAP = 5;
|
|
64
|
+
|
|
65
|
+
/** ⌘K command palette: search the served folder without leaving the page. */
|
|
66
|
+
export function SearchDialog({
|
|
67
|
+
open = true,
|
|
68
|
+
query = "",
|
|
69
|
+
results = [],
|
|
70
|
+
onQueryChange,
|
|
71
|
+
onSelect,
|
|
72
|
+
onClose,
|
|
73
|
+
className,
|
|
74
|
+
...rest
|
|
75
|
+
}: SearchDialogProps) {
|
|
76
|
+
const [highlighted, setHighlighted] = useState(0);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setHighlighted(0);
|
|
80
|
+
}, [query, results, open]);
|
|
81
|
+
|
|
82
|
+
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
83
|
+
if (event.key === "ArrowDown") {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
setHighlighted((current) => Math.min(current + 1, Math.max(results.length - 1, 0)));
|
|
86
|
+
} else if (event.key === "ArrowUp") {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setHighlighted((current) => Math.max(current - 1, 0));
|
|
89
|
+
} else if (event.key === "Enter") {
|
|
90
|
+
const result = results[highlighted];
|
|
91
|
+
if (result) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onSelect?.(result);
|
|
94
|
+
}
|
|
95
|
+
} else if (event.key === "Escape") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
onClose?.();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<AnimatePresence>
|
|
103
|
+
{open ? (
|
|
104
|
+
<motion.div
|
|
105
|
+
key="scrim"
|
|
106
|
+
onClick={onClose}
|
|
107
|
+
{...VARIANTS.scrim}
|
|
108
|
+
className="fixed inset-0 z-[var(--z-modal)] flex justify-center bg-[var(--scrim)] pt-[10vh] backdrop-blur-sm"
|
|
109
|
+
>
|
|
110
|
+
<motion.div
|
|
111
|
+
onClick={(event) => event.stopPropagation()}
|
|
112
|
+
onKeyDown={handleKeyDown}
|
|
113
|
+
{...VARIANTS.pop}
|
|
114
|
+
className={
|
|
115
|
+
"w-[560px] max-w-[92vw] self-start overflow-hidden rounded-xl border border-border-default bg-surface-raised shadow-lg" +
|
|
116
|
+
(className ? " " + className : "")
|
|
117
|
+
}
|
|
118
|
+
{...rest}
|
|
119
|
+
>
|
|
120
|
+
<div className="flex h-12 items-center gap-3 border-b border-border-subtle px-4">
|
|
121
|
+
<Icon name="search" size="md" className="text-text-subtle" />
|
|
122
|
+
<input
|
|
123
|
+
autoFocus
|
|
124
|
+
data-bare-focus
|
|
125
|
+
value={query}
|
|
126
|
+
onChange={(event) => onQueryChange?.(event.target.value)}
|
|
127
|
+
placeholder="Search docs"
|
|
128
|
+
className="flex-1 border-0 bg-transparent text-[18px] font-normal text-text-body outline-none placeholder:text-text-subtle"
|
|
129
|
+
/>
|
|
130
|
+
<Kbd>esc</Kbd>
|
|
131
|
+
</div>
|
|
132
|
+
<motion.div
|
|
133
|
+
layout
|
|
134
|
+
transition={TRANSITIONS.glide}
|
|
135
|
+
className="max-h-80 overflow-y-auto p-2"
|
|
136
|
+
>
|
|
137
|
+
{results.length === 0 ? (
|
|
138
|
+
<div className="p-6 text-center text-[13px] leading-normal font-medium text-text-subtle">
|
|
139
|
+
No matches
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
results.map((result, index) => {
|
|
143
|
+
const active = index === highlighted;
|
|
144
|
+
return (
|
|
145
|
+
<motion.div
|
|
146
|
+
key={(result.path ?? "") + index}
|
|
147
|
+
layout
|
|
148
|
+
initial={{ opacity: 0, y: 4 }}
|
|
149
|
+
animate={{ opacity: 1, y: 0 }}
|
|
150
|
+
transition={{
|
|
151
|
+
...TRANSITIONS.glide,
|
|
152
|
+
delay: Math.min(index, STAGGER_CAP - 1) * 0.02,
|
|
153
|
+
}}
|
|
154
|
+
onMouseEnter={() => setHighlighted(index)}
|
|
155
|
+
onClick={() => onSelect?.(result)}
|
|
156
|
+
className={
|
|
157
|
+
"flex cursor-pointer items-start gap-3 rounded-md p-2.5 " +
|
|
158
|
+
(active ? "bg-surface-hover" : "bg-transparent")
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<Icon name="file-text" size="sm" className="mt-0.5 text-text-subtle" />
|
|
162
|
+
<div className="min-w-0 flex-1">
|
|
163
|
+
{result.label ? (
|
|
164
|
+
<div className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[length:var(--size-2xs)] text-text-subtle">
|
|
165
|
+
{highlight(result.label, result.terms)}
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
<div className="text-[13px] leading-normal font-semibold text-text-heading">
|
|
169
|
+
{highlight(result.title, result.terms)}
|
|
170
|
+
</div>
|
|
171
|
+
{result.excerpt ? (
|
|
172
|
+
<div className="mt-px overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-normal text-text-muted">
|
|
173
|
+
{highlight(result.excerpt, result.terms)}
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
</motion.div>
|
|
178
|
+
);
|
|
179
|
+
})
|
|
180
|
+
)}
|
|
181
|
+
</motion.div>
|
|
182
|
+
</motion.div>
|
|
183
|
+
</motion.div>
|
|
184
|
+
) : null}
|
|
185
|
+
</AnimatePresence>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState, type HTMLAttributes } from "react";
|
|
2
|
+
import { Icon } from "./Icon";
|
|
3
|
+
|
|
4
|
+
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
/** Selected/filtering state. */
|
|
6
|
+
active?: boolean;
|
|
7
|
+
/** Renders a dismiss affordance. */
|
|
8
|
+
onRemove?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Small pill used for filter chips and metadata labels. */
|
|
12
|
+
export function Tag({ children, onRemove, active, className, ...rest }: TagProps) {
|
|
13
|
+
const [hover, setHover] = useState(false);
|
|
14
|
+
return (
|
|
15
|
+
<span
|
|
16
|
+
onMouseEnter={() => setHover(true)}
|
|
17
|
+
onMouseLeave={() => setHover(false)}
|
|
18
|
+
className={
|
|
19
|
+
"inline-flex h-6 items-center gap-1.5 rounded-pill border font-sans font-medium leading-normal text-[length:var(--size-sm)] transition-colors " +
|
|
20
|
+
(onRemove ? "pr-1.5 pl-2.5" : "px-2.5") +
|
|
21
|
+
" " +
|
|
22
|
+
(active
|
|
23
|
+
? "border-teal-200 bg-surface-accent-soft text-text-accent"
|
|
24
|
+
: hover
|
|
25
|
+
? "border-border-default bg-surface-hover text-text-muted"
|
|
26
|
+
: "border-border-default bg-surface-sunken text-text-muted") +
|
|
27
|
+
(className ? " " + className : "")
|
|
28
|
+
}
|
|
29
|
+
{...rest}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
{onRemove ? (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={onRemove}
|
|
36
|
+
aria-label="Remove"
|
|
37
|
+
className="inline-flex cursor-pointer rounded-pill border-0 bg-transparent p-0.5 text-inherit"
|
|
38
|
+
>
|
|
39
|
+
<Icon name="x" size={12} />
|
|
40
|
+
</button>
|
|
41
|
+
) : null}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useEffect, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CheckmarkIcon,
|
|
4
|
+
ErrorIcon,
|
|
5
|
+
ToastBar,
|
|
6
|
+
Toaster,
|
|
7
|
+
toast,
|
|
8
|
+
useToasterStore,
|
|
9
|
+
type Toast as HotToast,
|
|
10
|
+
} from "react-hot-toast";
|
|
11
|
+
import { Icon } from "./Icon";
|
|
12
|
+
import { nextCount } from "./toast-count";
|
|
13
|
+
import { docModuleCache } from "../doc-module-cache";
|
|
14
|
+
|
|
15
|
+
export type ToastTone = "info" | "ok" | "warn" | "danger";
|
|
16
|
+
|
|
17
|
+
export interface ToastInput {
|
|
18
|
+
tone: ToastTone;
|
|
19
|
+
/** The whole toast — one line, sentence case, human-written. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Lucide icon name overriding the tone's icon (e.g. "download", "trash-2"). */
|
|
22
|
+
icon?: string;
|
|
23
|
+
/** Pushes sharing a key collapse into one card: repeat push = same card, ×N counter, bounce, dismiss timer restarted. */
|
|
24
|
+
dedupeKey?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TONE_FG: Record<ToastTone, string> = {
|
|
28
|
+
info: "text-status-info-fg",
|
|
29
|
+
ok: "text-status-ok-fg",
|
|
30
|
+
warn: "text-status-warn-fg",
|
|
31
|
+
danger: "text-status-danger-fg",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// react-hot-toast only exposes live state through this hook, so the module
|
|
35
|
+
// scope below can't just read a store synchronously the way `pushToast` (a
|
|
36
|
+
// plain function, called from outside React) needs to. Instead a child
|
|
37
|
+
// component mirrors the hook's state into `liveIds` on every change; that
|
|
38
|
+
// mirror is what `nextCount` consults. Driving it off `visible` (not just
|
|
39
|
+
// presence in the list, which includes toasts mid fade-out) is what keeps
|
|
40
|
+
// counting correct under pause-on-hover: a hovered toast's dismiss timer is
|
|
41
|
+
// paused, not fired, so it stays "live" and a repeat push still increments
|
|
42
|
+
// its counter instead of resetting to 1.
|
|
43
|
+
let liveIds = new Set<string>();
|
|
44
|
+
const counts = new Map<string, number>();
|
|
45
|
+
|
|
46
|
+
function LiveIdMirror() {
|
|
47
|
+
const { toasts } = useToasterStore();
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
liveIds = new Set(toasts.filter((item) => item.visible).map((item) => item.id));
|
|
50
|
+
}, [toasts]);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* react-hot-toast sets each bar's enter/exit `animation` inline (see its
|
|
56
|
+
* source), so a plain class on `ToastBar` itself would lose to that inline
|
|
57
|
+
* style — or, with `!important`, would clobber the exit animation too. This
|
|
58
|
+
* wrapper carries the attention-bounce class instead, one layer out from
|
|
59
|
+
* where react-hot-toast writes its own animation.
|
|
60
|
+
*/
|
|
61
|
+
function Bump({ pulse, children }: { pulse: number; children: ReactNode }) {
|
|
62
|
+
const bumpClass = pulse > 0 ? (pulse % 2 === 1 ? "toast-bump-a" : "toast-bump-b") : undefined;
|
|
63
|
+
return <div className={bumpClass}>{children}</div>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toneIcon(tone: ToastTone, iconName: string | undefined) {
|
|
67
|
+
if (iconName) return <Icon name={iconName} size="md" className={TONE_FG[tone]} />;
|
|
68
|
+
switch (tone) {
|
|
69
|
+
case "ok":
|
|
70
|
+
return <CheckmarkIcon primary="var(--status-ok-fg)" secondary="var(--surface-raised)" />;
|
|
71
|
+
case "danger":
|
|
72
|
+
return <ErrorIcon primary="var(--status-danger-fg)" secondary="var(--surface-raised)" />;
|
|
73
|
+
case "warn":
|
|
74
|
+
return <Icon name="triangle-alert" size="md" className="text-status-warn-fg" />;
|
|
75
|
+
case "info":
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Queues a toast; repeat pushes sharing `dedupeKey` collapse into the same
|
|
82
|
+
* card (message updates, ×N counter, attention bounce, dismiss timer
|
|
83
|
+
* restarted) instead of stacking a second one. Returns the toast's id.
|
|
84
|
+
*/
|
|
85
|
+
export function pushToast(input: ToastInput): string {
|
|
86
|
+
const id = input.dedupeKey;
|
|
87
|
+
const count = input.dedupeKey ? nextCount(liveIds, counts, input.dedupeKey) : 1;
|
|
88
|
+
const message =
|
|
89
|
+
count > 1 ? (
|
|
90
|
+
<>
|
|
91
|
+
{input.text}
|
|
92
|
+
<span className="ml-2 rounded-pill border border-border-default bg-surface-sunken px-1.5 text-[11px] tabular-nums text-text-muted">
|
|
93
|
+
×{count}
|
|
94
|
+
</span>
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
input.text
|
|
98
|
+
);
|
|
99
|
+
const toastId = toast.custom(
|
|
100
|
+
(liveToast: HotToast) => (
|
|
101
|
+
<Bump pulse={count - 1}>
|
|
102
|
+
<ToastBar toast={{ ...liveToast, message }} />
|
|
103
|
+
</Bump>
|
|
104
|
+
),
|
|
105
|
+
{
|
|
106
|
+
id,
|
|
107
|
+
duration: 4000,
|
|
108
|
+
// ToastBar picks its enter/exit direction from the toast, not the
|
|
109
|
+
// Toaster; without this it animates as if the stack were top-center.
|
|
110
|
+
position: "bottom-right",
|
|
111
|
+
icon: toneIcon(input.tone, input.icon),
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
// The mirror only catches up after React commits; mark the id live now so
|
|
115
|
+
// a second push in the same tick still counts as a repeat.
|
|
116
|
+
liveIds.add(toastId);
|
|
117
|
+
return toastId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Re-exported for symmetry with `pushToast`. */
|
|
121
|
+
export const dismissToast = toast.dismiss;
|
|
122
|
+
|
|
123
|
+
const TOAST_STYLE: CSSProperties = {
|
|
124
|
+
background: "var(--surface-raised)",
|
|
125
|
+
color: "var(--text-heading)",
|
|
126
|
+
border: "1px solid var(--border-default)",
|
|
127
|
+
borderRadius: 8,
|
|
128
|
+
boxShadow: "var(--shadow-toast)",
|
|
129
|
+
font: "500 14px/1.4 var(--font-sans)",
|
|
130
|
+
maxWidth: 360,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Fixed bottom-right stack. Mount once near the app root. */
|
|
134
|
+
export function ToastStack() {
|
|
135
|
+
// The server names the doc that was edited (`mdxserve:doc-changed`, sent
|
|
136
|
+
// from its file watcher). Vite's own `vite:afterUpdate` can't be used for
|
|
137
|
+
// this: a doc module has no HMR boundary, so the update is attributed to
|
|
138
|
+
// the importer that accepted it (App.tsx), and the Tailwind rebuild the
|
|
139
|
+
// edit triggers shows up as a second update for app.css.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!import.meta.hot) return;
|
|
142
|
+
// The event covers every doc under every mounted root, so keep only the
|
|
143
|
+
// docs this tab has actually loaded (the old vite:afterUpdate toast was
|
|
144
|
+
// scoped to loaded modules the same way) — an agent editing forty
|
|
145
|
+
// unrelated files shouldn't rain toasts on a reader. Keyed by path so a
|
|
146
|
+
// burst of saves to one doc refreshes a single card (counter + bounce,
|
|
147
|
+
// timer restarted) instead of stacking copies.
|
|
148
|
+
const handleDocChanged = (data: {
|
|
149
|
+
files?: Array<{ path: string; root: string; rel: string }>;
|
|
150
|
+
}) => {
|
|
151
|
+
const files = (data?.files ?? []).filter((file) => docModuleCache.has(file.path));
|
|
152
|
+
const [first] = files;
|
|
153
|
+
if (!first) return;
|
|
154
|
+
if (files.length === 1) {
|
|
155
|
+
pushToast({
|
|
156
|
+
tone: "info",
|
|
157
|
+
text: `Reloaded ${first.rel}`,
|
|
158
|
+
dedupeKey: `reload:${first.path}`,
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
pushToast({
|
|
162
|
+
tone: "info",
|
|
163
|
+
text: `Reloaded ${files.length} docs`,
|
|
164
|
+
dedupeKey: "reload:many",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
import.meta.hot.on("mdxserve:doc-changed", handleDocChanged);
|
|
169
|
+
return () => {
|
|
170
|
+
import.meta.hot?.off?.("mdxserve:doc-changed", handleDocChanged);
|
|
171
|
+
};
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<>
|
|
176
|
+
<LiveIdMirror />
|
|
177
|
+
{/* Inline style, not a class: goober (react-hot-toast's runtime CSS-in-JS)
|
|
178
|
+
injects the base toast class after this stylesheet loads, so only an
|
|
179
|
+
inline style is guaranteed to win. The custom properties still resolve
|
|
180
|
+
at computed-style time, so light/dark both come through correctly. */}
|
|
181
|
+
<Toaster
|
|
182
|
+
position="bottom-right"
|
|
183
|
+
gutter={8}
|
|
184
|
+
containerStyle={{ zIndex: "var(--z-toast)", inset: 16 }}
|
|
185
|
+
toastOptions={{ duration: 4000, style: TOAST_STYLE }}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
}
|