@karimsa/mdxserve 0.0.0-stage → 0.2.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 +113 -5
- package/skills/mdxserve/SKILL.md +178 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { Icon } from "./ui/Icon";
|
|
4
|
+
import { IconButton } from "./ui/IconButton";
|
|
5
|
+
import { ExpandModal } from "./ui/ExpandModal";
|
|
6
|
+
import { TRANSITIONS, VARIANTS } from "./motion";
|
|
7
|
+
import {
|
|
8
|
+
chartDiagramKeyword,
|
|
9
|
+
chartDiagramMessage,
|
|
10
|
+
type ChartDiagramKeyword,
|
|
11
|
+
} from "./mermaid-chart.js";
|
|
12
|
+
|
|
13
|
+
type State =
|
|
14
|
+
| { kind: "loading" }
|
|
15
|
+
| { kind: "ok"; svg: string }
|
|
16
|
+
| { kind: "error"; message: string }
|
|
17
|
+
| { kind: "unsupported"; keyword: ChartDiagramKeyword };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared inner content (icon + heading + body) for the two notice cards this
|
|
21
|
+
* component can show in place of a diagram: a real mermaid parse/render
|
|
22
|
+
* error, and a chart-diagram fence that is rejected on purpose. The caller
|
|
23
|
+
* supplies the outer `motion.div` (so it stays a direct child of
|
|
24
|
+
* `AnimatePresence`) and this only fills it in, keeping the two branches
|
|
25
|
+
* visually identical and in sync.
|
|
26
|
+
*/
|
|
27
|
+
function DiagramNoticeBody({ heading, children }: { heading: string; children: ReactNode }) {
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<Icon name="octagon-alert" size="sm" className="mt-0.5 shrink-0 text-status-danger-fg" />
|
|
31
|
+
<div>
|
|
32
|
+
<p className="mb-2 font-semibold text-status-danger-fg">{heading}</p>
|
|
33
|
+
{children}
|
|
34
|
+
</div>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined;
|
|
40
|
+
|
|
41
|
+
/** Lazy-load mermaid (it's large) only when a page actually contains a diagram. */
|
|
42
|
+
function loadMermaid() {
|
|
43
|
+
if (!mermaidPromise) {
|
|
44
|
+
mermaidPromise = import("mermaid").then((mod) => mod.default);
|
|
45
|
+
}
|
|
46
|
+
return mermaidPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cssVar(name: string, fallback: string): string {
|
|
50
|
+
if (typeof window === "undefined") return fallback;
|
|
51
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
52
|
+
return value || fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Themes mermaid from the design tokens so diagrams follow light/dark automatically. */
|
|
56
|
+
function themeVariables() {
|
|
57
|
+
return {
|
|
58
|
+
background: cssVar("--diagram-bg", "#ffffff"),
|
|
59
|
+
primaryColor: cssVar("--diagram-node-bg", "#e8f6f5"),
|
|
60
|
+
primaryBorderColor: cssVar("--diagram-node-border", "#2ba5a2"),
|
|
61
|
+
primaryTextColor: cssVar("--diagram-node-fg", "#085b59"),
|
|
62
|
+
lineColor: cssVar("--diagram-line", "#a9a8a0"),
|
|
63
|
+
textColor: cssVar("--text-body", "#2b2a27"),
|
|
64
|
+
fontFamily: cssVar("--font-core", "Manrope, sans-serif"),
|
|
65
|
+
// Secondary / tertiary nodes (subgraphs, alt shapes) use the sunken surface
|
|
66
|
+
// so nothing falls back to mermaid's own hues.
|
|
67
|
+
secondaryColor: cssVar("--diagram-alt-node-bg", "#f6f6f3"),
|
|
68
|
+
secondaryBorderColor: cssVar("--diagram-alt-node-border", "#cfcec7"),
|
|
69
|
+
secondaryTextColor: cssVar("--text-body", "#2b2a27"),
|
|
70
|
+
tertiaryColor: cssVar("--surface-sunken", "#f6f6f3"),
|
|
71
|
+
tertiaryBorderColor: cssVar("--border-default", "#e3e2dd"),
|
|
72
|
+
tertiaryTextColor: cssVar("--text-body", "#2b2a27"),
|
|
73
|
+
// Edge labels sit on the card surface instead of mermaid's olive tint.
|
|
74
|
+
edgeLabelBackground: cssVar("--diagram-label-bg", "#ffffff"),
|
|
75
|
+
clusterBkg: cssVar("--surface-sunken", "#f6f6f3"),
|
|
76
|
+
clusterBorder: cssVar("--border-default", "#e3e2dd"),
|
|
77
|
+
// ER diagrams: attribute rows default to mermaid's hard-coded white / #f2f2f2,
|
|
78
|
+
// unreadable under the light `textColor` in dark mode. Alternate the card and
|
|
79
|
+
// sunken surfaces instead so rows follow the theme like every other node.
|
|
80
|
+
attributeBackgroundColorOdd: cssVar("--diagram-bg", "#ffffff"),
|
|
81
|
+
attributeBackgroundColorEven: cssVar("--diagram-alt-node-bg", "#f6f6f3"),
|
|
82
|
+
// Sequence diagrams.
|
|
83
|
+
actorBkg: cssVar("--diagram-node-bg", "#e8f6f5"),
|
|
84
|
+
actorBorder: cssVar("--diagram-node-border", "#2ba5a2"),
|
|
85
|
+
actorTextColor: cssVar("--diagram-node-fg", "#085b59"),
|
|
86
|
+
actorLineColor: cssVar("--diagram-line", "#a9a8a0"),
|
|
87
|
+
signalColor: cssVar("--text-body", "#2b2a27"),
|
|
88
|
+
signalTextColor: cssVar("--text-body", "#2b2a27"),
|
|
89
|
+
labelBoxBkgColor: cssVar("--surface-sunken", "#f6f6f3"),
|
|
90
|
+
labelBoxBorderColor: cssVar("--border-default", "#e3e2dd"),
|
|
91
|
+
labelTextColor: cssVar("--text-body", "#2b2a27"),
|
|
92
|
+
loopTextColor: cssVar("--text-body", "#2b2a27"),
|
|
93
|
+
noteBkgColor: cssVar("--status-warn-bg", "#fbeccd"),
|
|
94
|
+
noteBorderColor: cssVar("--status-warn-fg", "#855603"),
|
|
95
|
+
noteTextColor: cssVar("--status-warn-fg", "#855603"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Diagrams re-render whenever the reader flips light/dark: a single shared
|
|
100
|
+
MutationObserver on <html data-theme> notifies every mounted MermaidDiagram
|
|
101
|
+
instead of each one polling or wiring its own observer. */
|
|
102
|
+
const themeListeners = new Set<() => void>();
|
|
103
|
+
let themeObserver: MutationObserver | undefined;
|
|
104
|
+
|
|
105
|
+
function ensureThemeObserver() {
|
|
106
|
+
if (themeObserver || typeof MutationObserver === "undefined") return;
|
|
107
|
+
themeObserver = new MutationObserver(() => {
|
|
108
|
+
for (const listener of themeListeners) listener();
|
|
109
|
+
});
|
|
110
|
+
themeObserver.observe(document.documentElement, {
|
|
111
|
+
attributes: true,
|
|
112
|
+
attributeFilter: ["data-theme"],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function useThemeTick(): number {
|
|
117
|
+
const [tick, setTick] = useState(0);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
ensureThemeObserver();
|
|
120
|
+
const listener = () => setTick((current) => current + 1);
|
|
121
|
+
themeListeners.add(listener);
|
|
122
|
+
return () => {
|
|
123
|
+
themeListeners.delete(listener);
|
|
124
|
+
};
|
|
125
|
+
}, []);
|
|
126
|
+
return tick;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Renders mermaid `source` to inline SVG inside the code card. `toolbar` is
|
|
131
|
+
* mirrored into the expanded modal's header so view controls that live in the
|
|
132
|
+
* code frame (the flow-direction toggle) stay reachable at full size.
|
|
133
|
+
*/
|
|
134
|
+
export function MermaidDiagram({ source, toolbar }: { source: string; toolbar?: ReactNode }) {
|
|
135
|
+
// Seeded from the source so a chart-diagram fence never shows the
|
|
136
|
+
// "Rendering diagram…" placeholder — there is nothing to load for it.
|
|
137
|
+
const [state, setState] = useState<State>(() => {
|
|
138
|
+
const keyword = chartDiagramKeyword(source);
|
|
139
|
+
return keyword === null ? { kind: "loading" } : { kind: "unsupported", keyword };
|
|
140
|
+
});
|
|
141
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
|
|
142
|
+
const themeTick = useThemeTick();
|
|
143
|
+
const renderCount = useRef(0);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
// pie / xychart-beta / quadrantChart / sankey-beta draw data charts,
|
|
147
|
+
// which mdxserve's <Chart> builtin already covers, and covers better
|
|
148
|
+
// (design tokens, hover, expand). Reject on purpose, before mermaid is
|
|
149
|
+
// ever downloaded.
|
|
150
|
+
const keyword = chartDiagramKeyword(source);
|
|
151
|
+
if (keyword !== null) {
|
|
152
|
+
setState({ kind: "unsupported", keyword });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
// A fresh id for every render. mermaid.render() first removes any element
|
|
158
|
+
// already carrying that id from the document, so reusing one would yank
|
|
159
|
+
// the SVG still on screen (the one svg-pan-zoom holds) out from under the
|
|
160
|
+
// reader until the new one lands. A fresh id also sidesteps mermaid's
|
|
161
|
+
// render cache, which is keyed by id and can serve stale colours after a
|
|
162
|
+
// theme change.
|
|
163
|
+
renderCount.current += 1;
|
|
164
|
+
const renderId = `mermaid-${id}-${renderCount.current}`;
|
|
165
|
+
// Only the first render shows the placeholder. A re-render (theme flip,
|
|
166
|
+
// direction change) keeps the previous SVG up until the new one lands, so
|
|
167
|
+
// the card doesn't flash and an open modal stays open.
|
|
168
|
+
setState((current) => (current.kind === "ok" ? current : { kind: "loading" }));
|
|
169
|
+
loadMermaid()
|
|
170
|
+
.then((mermaid) => {
|
|
171
|
+
mermaid.initialize({
|
|
172
|
+
startOnLoad: false,
|
|
173
|
+
theme: "base",
|
|
174
|
+
themeVariables: themeVariables(),
|
|
175
|
+
securityLevel: "strict",
|
|
176
|
+
});
|
|
177
|
+
return mermaid.render(renderId, source);
|
|
178
|
+
})
|
|
179
|
+
.then(({ svg }) => {
|
|
180
|
+
if (!cancelled) setState({ kind: "ok", svg });
|
|
181
|
+
})
|
|
182
|
+
.catch((error: unknown) => {
|
|
183
|
+
// mermaid.render() draws into a temporary `<div id="d<renderId>">` it
|
|
184
|
+
// appends to <body>, and on success removes it — but on a parse
|
|
185
|
+
// error it rethrows *before* that cleanup (mermaid 10.9: the
|
|
186
|
+
// `parseEncounteredException` check precedes the `remove()`), so
|
|
187
|
+
// every failed render would leave a "Syntax error in text" SVG at
|
|
188
|
+
// the bottom of the page. Each HMR pass on a broken diagram adds
|
|
189
|
+
// another; take ours down here.
|
|
190
|
+
document.getElementById(`d${renderId}`)?.remove();
|
|
191
|
+
if (cancelled) return;
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
setState({ kind: "error", message });
|
|
194
|
+
});
|
|
195
|
+
return () => {
|
|
196
|
+
cancelled = true;
|
|
197
|
+
};
|
|
198
|
+
}, [source, id, themeTick]);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
202
|
+
{state.kind === "loading" ? (
|
|
203
|
+
<motion.div
|
|
204
|
+
key="loading"
|
|
205
|
+
{...VARIANTS.fade}
|
|
206
|
+
className="px-4 py-8 text-center text-[13px] leading-normal text-text-subtle"
|
|
207
|
+
>
|
|
208
|
+
Rendering diagram…
|
|
209
|
+
</motion.div>
|
|
210
|
+
) : state.kind === "error" ? (
|
|
211
|
+
<motion.div
|
|
212
|
+
key="error"
|
|
213
|
+
{...VARIANTS.fade}
|
|
214
|
+
className="flex items-start gap-2 px-4 py-4 text-[13px] leading-normal"
|
|
215
|
+
>
|
|
216
|
+
<DiagramNoticeBody heading="Mermaid could not render this diagram">
|
|
217
|
+
<pre className="whitespace-pre-wrap font-mono text-[length:var(--size-xs)] text-text-muted">
|
|
218
|
+
{state.message}
|
|
219
|
+
</pre>
|
|
220
|
+
</DiagramNoticeBody>
|
|
221
|
+
</motion.div>
|
|
222
|
+
) : state.kind === "unsupported" ? (
|
|
223
|
+
<motion.div
|
|
224
|
+
key="unsupported"
|
|
225
|
+
{...VARIANTS.fade}
|
|
226
|
+
className="flex items-start gap-2 px-4 py-4 text-[13px] leading-normal"
|
|
227
|
+
>
|
|
228
|
+
<DiagramNoticeBody heading={`Mermaid ${state.keyword} charts don't render here`}>
|
|
229
|
+
<p className="text-text-muted">{chartDiagramMessage(state.keyword)}</p>
|
|
230
|
+
</DiagramNoticeBody>
|
|
231
|
+
</motion.div>
|
|
232
|
+
) : (
|
|
233
|
+
<motion.div key="ok" {...VARIANTS.fade}>
|
|
234
|
+
<ExpandableDiagram svg={state.svg} toolbar={toolbar} />
|
|
235
|
+
</motion.div>
|
|
236
|
+
)}
|
|
237
|
+
</AnimatePresence>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Hosts the rendered SVG in a pan/zoom viewport (drag to pan, wheel /
|
|
243
|
+
* double-click to zoom, +/−/reset controls) that fills its parent. The inline
|
|
244
|
+
* card gives it a fixed height; the full-screen modal gives it the whole panel.
|
|
245
|
+
*/
|
|
246
|
+
function PanZoomSvg({
|
|
247
|
+
svg,
|
|
248
|
+
viewportClassName,
|
|
249
|
+
onExpand,
|
|
250
|
+
}: {
|
|
251
|
+
svg: string;
|
|
252
|
+
/** Sizes the viewport; the inline card uses `h-96`, the modal `h-full`. */
|
|
253
|
+
viewportClassName: string;
|
|
254
|
+
/** When set, an expand control opens the diagram in the full-screen modal. */
|
|
255
|
+
onExpand?: () => void;
|
|
256
|
+
}) {
|
|
257
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
258
|
+
const instanceRef = useRef<SvgPanZoom.Instance | null>(null);
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const host = hostRef.current;
|
|
262
|
+
if (!host) return;
|
|
263
|
+
// Inject the markup imperatively (not via a React prop) so a re-render of
|
|
264
|
+
// this component never resets the DOM that svg-pan-zoom mutates
|
|
265
|
+
// (its viewport group + transform).
|
|
266
|
+
host.innerHTML = svg;
|
|
267
|
+
const el = host.querySelector("svg");
|
|
268
|
+
if (!el) return;
|
|
269
|
+
|
|
270
|
+
// svg-pan-zoom touches `window` at module load, and this file is reachable
|
|
271
|
+
// from client/builtins/index.ts (via CodeBlock's CodeFrameHeader), which
|
|
272
|
+
// scripts/build-registry.ts imports under plain Node — so load it lazily,
|
|
273
|
+
// like mermaid itself.
|
|
274
|
+
let cancelled = false;
|
|
275
|
+
let instance: SvgPanZoom.Instance | null = null;
|
|
276
|
+
let observer: ResizeObserver | null = null;
|
|
277
|
+
import("svg-pan-zoom").then(({ default: svgPanZoom }) => {
|
|
278
|
+
if (cancelled) return;
|
|
279
|
+
|
|
280
|
+
// Mermaid sizes the SVG with a max-width + 100% width; svg-pan-zoom needs
|
|
281
|
+
// it to fill the viewport so the viewBox can be fitted and panned.
|
|
282
|
+
el.style.maxWidth = "none";
|
|
283
|
+
el.style.width = "100%";
|
|
284
|
+
el.style.height = "100%";
|
|
285
|
+
el.setAttribute("width", "100%");
|
|
286
|
+
el.setAttribute("height", "100%");
|
|
287
|
+
|
|
288
|
+
instance = svgPanZoom(el, {
|
|
289
|
+
zoomEnabled: true,
|
|
290
|
+
panEnabled: true,
|
|
291
|
+
controlIconsEnabled: false, // we render our own controls below
|
|
292
|
+
mouseWheelZoomEnabled: true,
|
|
293
|
+
dblClickZoomEnabled: true,
|
|
294
|
+
fit: true,
|
|
295
|
+
center: true,
|
|
296
|
+
minZoom: 0.2,
|
|
297
|
+
maxZoom: 10,
|
|
298
|
+
zoomScaleSensitivity: 0.3,
|
|
299
|
+
});
|
|
300
|
+
instanceRef.current = instance;
|
|
301
|
+
|
|
302
|
+
const live = instance;
|
|
303
|
+
observer = new ResizeObserver(() => {
|
|
304
|
+
live.resize();
|
|
305
|
+
live.fit();
|
|
306
|
+
live.center();
|
|
307
|
+
});
|
|
308
|
+
observer.observe(host);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return () => {
|
|
312
|
+
cancelled = true;
|
|
313
|
+
observer?.disconnect();
|
|
314
|
+
try {
|
|
315
|
+
// destroy() resets the zoom via the SVG's CTM, which throws an
|
|
316
|
+
// InvalidStateError once the element is detached or zero-sized
|
|
317
|
+
// (theme re-render, route change mid-animation). Nothing to undo then.
|
|
318
|
+
instance?.destroy();
|
|
319
|
+
} catch {
|
|
320
|
+
// ignore
|
|
321
|
+
}
|
|
322
|
+
instanceRef.current = null;
|
|
323
|
+
host.innerHTML = "";
|
|
324
|
+
};
|
|
325
|
+
}, [svg]);
|
|
326
|
+
|
|
327
|
+
function reset() {
|
|
328
|
+
const instance = instanceRef.current;
|
|
329
|
+
if (!instance) return;
|
|
330
|
+
instance.resize();
|
|
331
|
+
instance.fit();
|
|
332
|
+
instance.center();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="relative h-full">
|
|
337
|
+
{/* The host div is mutated imperatively (host.innerHTML = svg, above) and must
|
|
338
|
+
never be re-rendered by React/motion; the fade lives on this wrapper instead. */}
|
|
339
|
+
<motion.div
|
|
340
|
+
initial={{ opacity: 0 }}
|
|
341
|
+
animate={{ opacity: 1 }}
|
|
342
|
+
transition={TRANSITIONS.base}
|
|
343
|
+
className="h-full"
|
|
344
|
+
>
|
|
345
|
+
<div
|
|
346
|
+
ref={hostRef}
|
|
347
|
+
className={
|
|
348
|
+
"mermaid-viewport w-full cursor-grab select-none active:cursor-grabbing " +
|
|
349
|
+
viewportClassName
|
|
350
|
+
}
|
|
351
|
+
/>
|
|
352
|
+
</motion.div>
|
|
353
|
+
<motion.div
|
|
354
|
+
initial={{ opacity: 0 }}
|
|
355
|
+
animate={{ opacity: 1 }}
|
|
356
|
+
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
|
|
357
|
+
className="absolute right-3 bottom-3 flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-default bg-surface-card shadow-xs"
|
|
358
|
+
>
|
|
359
|
+
{onExpand ? (
|
|
360
|
+
<IconButton icon="expand" label="Expand diagram" size="sm" onClick={onExpand} />
|
|
361
|
+
) : null}
|
|
362
|
+
<IconButton
|
|
363
|
+
icon="plus"
|
|
364
|
+
label="Zoom in"
|
|
365
|
+
size="sm"
|
|
366
|
+
onClick={() => instanceRef.current?.zoomIn()}
|
|
367
|
+
/>
|
|
368
|
+
<IconButton icon="maximize" label="Reset view" size="sm" onClick={reset} />
|
|
369
|
+
<IconButton
|
|
370
|
+
icon="minus"
|
|
371
|
+
label="Zoom out"
|
|
372
|
+
size="sm"
|
|
373
|
+
onClick={() => instanceRef.current?.zoomOut()}
|
|
374
|
+
/>
|
|
375
|
+
</motion.div>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Inline diagram card with an expand control that opens the same SVG in a
|
|
382
|
+
* near-full-screen modal, where a second pan/zoom instance gets the whole
|
|
383
|
+
* viewport to explore a large diagram at scale.
|
|
384
|
+
*/
|
|
385
|
+
function ExpandableDiagram({ svg, toolbar }: { svg: string; toolbar?: ReactNode }) {
|
|
386
|
+
const [expanded, setExpanded] = useState(false);
|
|
387
|
+
return (
|
|
388
|
+
<>
|
|
389
|
+
{/* Only one copy of the markup is live at a time. Mermaid doesn't namespace
|
|
390
|
+
the ids it emits (markers, clip paths, gradients), so with both copies
|
|
391
|
+
mounted the modal's `url(#…)` references would resolve to the inline
|
|
392
|
+
SVG — the one svg-pan-zoom has already wrapped and transformed. The
|
|
393
|
+
card sits behind the scrim while expanded, so the placeholder that
|
|
394
|
+
holds its height never shows. */}
|
|
395
|
+
{expanded ? (
|
|
396
|
+
<div className="h-96 w-full" aria-hidden="true" />
|
|
397
|
+
) : (
|
|
398
|
+
<PanZoomSvg svg={svg} viewportClassName="h-96" onExpand={() => setExpanded(true)} />
|
|
399
|
+
)}
|
|
400
|
+
<DiagramModal
|
|
401
|
+
open={expanded}
|
|
402
|
+
svg={svg}
|
|
403
|
+
toolbar={toolbar}
|
|
404
|
+
onClose={() => setExpanded(false)}
|
|
405
|
+
/>
|
|
406
|
+
</>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function DiagramModal({
|
|
411
|
+
open,
|
|
412
|
+
svg,
|
|
413
|
+
toolbar,
|
|
414
|
+
onClose,
|
|
415
|
+
}: {
|
|
416
|
+
open: boolean;
|
|
417
|
+
svg: string;
|
|
418
|
+
toolbar?: ReactNode;
|
|
419
|
+
onClose: () => void;
|
|
420
|
+
}) {
|
|
421
|
+
return (
|
|
422
|
+
<ExpandModal
|
|
423
|
+
open={open}
|
|
424
|
+
onClose={onClose}
|
|
425
|
+
icon="image"
|
|
426
|
+
title="Diagram"
|
|
427
|
+
hint="Drag to pan · scroll to zoom"
|
|
428
|
+
actions={toolbar}
|
|
429
|
+
>
|
|
430
|
+
<div className="h-full bg-[var(--diagram-bg)]">
|
|
431
|
+
<PanZoomSvg svg={svg} viewportClassName="h-full" />
|
|
432
|
+
</div>
|
|
433
|
+
</ExpandModal>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
import { ErrorBox } from "./ErrorBox";
|
|
3
|
+
|
|
4
|
+
type RenderErrorBoundaryState = {
|
|
5
|
+
error: Error | null;
|
|
6
|
+
componentStack: string | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Catches render-time throws from the MDX document component (e.g. a bare
|
|
11
|
+
* identifier that escaped a template literal inside an inline code span) and
|
|
12
|
+
* shows ErrorBox instead of leaving the page blank. React error boundaries
|
|
13
|
+
* only work as class components — there is no hook equivalent.
|
|
14
|
+
*
|
|
15
|
+
* The parent keys this component on the cached module's identity, so an HMR
|
|
16
|
+
* re-import after a fix remounts the boundary and clears the stale error
|
|
17
|
+
* rather than getting stuck on the first throw.
|
|
18
|
+
*/
|
|
19
|
+
export class RenderErrorBoundary extends Component<
|
|
20
|
+
{ children: ReactNode },
|
|
21
|
+
RenderErrorBoundaryState
|
|
22
|
+
> {
|
|
23
|
+
state: RenderErrorBoundaryState = { error: null, componentStack: null };
|
|
24
|
+
|
|
25
|
+
static getDerivedStateFromError(error: Error): Partial<RenderErrorBoundaryState> {
|
|
26
|
+
return { error };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
30
|
+
this.setState({ componentStack: errorInfo.componentStack ?? null });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
render() {
|
|
34
|
+
const { error, componentStack } = this.state;
|
|
35
|
+
if (!error) return this.props.children;
|
|
36
|
+
|
|
37
|
+
const message = componentStack ? `${error.message}\n${componentStack.trim()}` : error.message;
|
|
38
|
+
return <ErrorBox message={message} />;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/client/Table.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TableHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MDXProvider `table` override. Tables with long cells (paths, code) can't
|
|
5
|
+
* shrink below their content, so without this they'd overflow the prose column
|
|
6
|
+
* and run under the TOC rail. Each table scrolls inside its own container.
|
|
7
|
+
*/
|
|
8
|
+
export function Table(props: TableHTMLAttributes<HTMLTableElement>) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="mdx-table-scroll">
|
|
11
|
+
<table {...props} />
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { motion, useAnimationControls } from "framer-motion";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MDXProvider `input` override. remark-gfm renders task-list items as
|
|
6
|
+
* `<input type="checkbox" disabled>`, which greys them out and swallows
|
|
7
|
+
* clicks. Swap `disabled` for `aria-disabled` so assistive tech still reports
|
|
8
|
+
* the state while the box keeps its accent colour, pin the value with a no-op
|
|
9
|
+
* change handler so clicking can't toggle what the Markdown says, and give a
|
|
10
|
+
* little spring bounce on click so it still feels alive.
|
|
11
|
+
*/
|
|
12
|
+
export function TaskCheckbox({ disabled: _disabled, ...props }: ComponentProps<"input">) {
|
|
13
|
+
const controls = useAnimationControls();
|
|
14
|
+
if (props.type !== "checkbox") return <input disabled={_disabled} {...props} />;
|
|
15
|
+
|
|
16
|
+
function bounce() {
|
|
17
|
+
void controls.start({
|
|
18
|
+
scale: [1, 0.8, 1.2, 1],
|
|
19
|
+
transition: { duration: 0.38, ease: "easeOut", times: [0, 0.25, 0.6, 1] },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// MDX only ever passes `type` and `checked` here; keep the spread narrow so
|
|
24
|
+
// React's and framer-motion's event prop types don't collide.
|
|
25
|
+
const { type, checked } = props;
|
|
26
|
+
return (
|
|
27
|
+
<motion.input
|
|
28
|
+
type={type}
|
|
29
|
+
checked={Boolean(checked)}
|
|
30
|
+
onChange={() => {}}
|
|
31
|
+
onClick={bounce}
|
|
32
|
+
aria-disabled="true"
|
|
33
|
+
tabIndex={-1}
|
|
34
|
+
animate={controls}
|
|
35
|
+
className="origin-center"
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/client/api.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { createTRPCClient, httpLink, type TRPCClient } from "@trpc/client";
|
|
3
|
+
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
|
4
|
+
// src/ is not in Vite's `fs.allow` (see src/rendering/vite.ts), so a *value* import of
|
|
5
|
+
// anything from src/ would 404 at runtime — this import is type-only and is
|
|
6
|
+
// erased entirely by the compiler.
|
|
7
|
+
import type { AppRouter } from "../src/api/router";
|
|
8
|
+
import type { inferRouterOutputs } from "@trpc/server";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Singletons guarded on `window`, mirroring `__mdxserveRoot` in entry.tsx:
|
|
12
|
+
* the entry module self-accepts HMR, so a fresh `QueryClient`/`trpcClient`
|
|
13
|
+
* created on every re-execution of this module would drop every cached
|
|
14
|
+
* tree/listing/search result mid-session. Reuse the same instances across
|
|
15
|
+
* HMR re-executions instead.
|
|
16
|
+
*/
|
|
17
|
+
interface MdxserveApiWindow extends Window {
|
|
18
|
+
__mdxserveQueryClient?: QueryClient;
|
|
19
|
+
__mdxserveTrpcClient?: TRPCClient<AppRouter>;
|
|
20
|
+
__mdxserveHmrBound?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// This module is also evaluated by the SSR render worker (client/ssr-entry.tsx
|
|
24
|
+
// → mdx-components.ts → MdSection.tsx imports `trpcClient` for the section
|
|
25
|
+
// editor), where there is no `window`. Nothing here is *called* during SSR —
|
|
26
|
+
// MdSection only reaches the client from a click handler — so in that case
|
|
27
|
+
// plain per-evaluation instances are fine; only the browser needs the
|
|
28
|
+
// HMR-surviving singletons.
|
|
29
|
+
const windowWithApi = typeof window === "undefined" ? undefined : (window as MdxserveApiWindow);
|
|
30
|
+
|
|
31
|
+
function makeQueryClient(): QueryClient {
|
|
32
|
+
return new QueryClient({
|
|
33
|
+
defaultOptions: {
|
|
34
|
+
queries: {
|
|
35
|
+
retry: false,
|
|
36
|
+
refetchOnWindowFocus: false,
|
|
37
|
+
gcTime: Infinity,
|
|
38
|
+
},
|
|
39
|
+
mutations: {
|
|
40
|
+
retry: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeTrpcClient(): TRPCClient<AppRouter> {
|
|
47
|
+
return createTRPCClient<AppRouter>({
|
|
48
|
+
// httpLink, not a batch link: GET for queries, POST for mutations, one
|
|
49
|
+
// request per call — keeps Network tab entries legible and matches the
|
|
50
|
+
// one-route-per-call shape the old fetch() call sites had.
|
|
51
|
+
links: [httpLink({ url: "/__mdxserve/trpc" })],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const queryClient: QueryClient = windowWithApi
|
|
56
|
+
? (windowWithApi.__mdxserveQueryClient ??= makeQueryClient())
|
|
57
|
+
: makeQueryClient();
|
|
58
|
+
|
|
59
|
+
export const trpcClient: TRPCClient<AppRouter> = windowWithApi
|
|
60
|
+
? (windowWithApi.__mdxserveTrpcClient ??= makeTrpcClient())
|
|
61
|
+
: makeTrpcClient();
|
|
62
|
+
|
|
63
|
+
export const trpc = createTRPCOptionsProxy<AppRouter>({ client: trpcClient, queryClient });
|
|
64
|
+
|
|
65
|
+
type RouterOutputs = inferRouterOutputs<AppRouter>;
|
|
66
|
+
|
|
67
|
+
// Files change on disk during a dev session; invalidate the relevant caches
|
|
68
|
+
// whenever Vite applies an HMR update (edits to existing files) or the
|
|
69
|
+
// watcher reports a listing change (deletions/creations, which don't
|
|
70
|
+
// trigger a module HMR update), so the sidebar/search/listings stay in sync
|
|
71
|
+
// with the watcher.
|
|
72
|
+
//
|
|
73
|
+
// Bound once behind a window flag rather than the usual `if (import.meta.hot)`
|
|
74
|
+
// module-scope guard: this module can itself be re-executed by HMR (anything
|
|
75
|
+
// that imports it is reachable from entry.tsx, which self-accepts), and
|
|
76
|
+
// without the flag every edit to this file would stack a duplicate pair of
|
|
77
|
+
// listeners.
|
|
78
|
+
if (import.meta.hot && windowWithApi && !windowWithApi.__mdxserveHmrBound) {
|
|
79
|
+
windowWithApi.__mdxserveHmrBound = true;
|
|
80
|
+
|
|
81
|
+
import.meta.hot.on("vite:afterUpdate", () => {
|
|
82
|
+
void queryClient.invalidateQueries({ ...trpc.getDocTree.queryFilter(), refetchType: "all" });
|
|
83
|
+
// Editing a doc's first h1 changes the title the sidebar shows for it,
|
|
84
|
+
// and that only ships as a module HMR update, not a listing-changed
|
|
85
|
+
// event — so refetch every listing currently mounted on screen (not
|
|
86
|
+
// every one ever cached) to pick the new title up.
|
|
87
|
+
void queryClient.invalidateQueries({ ...trpc.getFolderListing.queryFilter(), type: "active" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
import.meta.hot.on("mdxserve:listing-changed", (data: { dirs?: string[] }) => {
|
|
91
|
+
void queryClient.invalidateQueries({ ...trpc.getDocTree.queryFilter(), refetchType: "all" });
|
|
92
|
+
for (const changedDir of data?.dirs ?? []) {
|
|
93
|
+
void queryClient.invalidateQueries({
|
|
94
|
+
...trpc.getFolderListing.queryFilter({ path: changedDir }),
|
|
95
|
+
refetchType: "all",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The set of served roots itself changed (roots were added/removed at
|
|
101
|
+
// runtime, e.g. via `mdxserve roots add` / `remove`). The
|
|
102
|
+
// tree is now stale everywhere it's rendered (home page, sidebar), and any
|
|
103
|
+
// folder listing currently on screen may live under a root that no longer
|
|
104
|
+
// exists. This module can't import client/router.ts's `navigate` (that
|
|
105
|
+
// would be a cycle: router.ts already imports from here), so re-dispatch
|
|
106
|
+
// as a plain window event and let useRouter — which owns navigation and
|
|
107
|
+
// knows the current route — decide whether to redirect away from a
|
|
108
|
+
// removed root.
|
|
109
|
+
import.meta.hot.on(
|
|
110
|
+
"mdxserve:roots-changed",
|
|
111
|
+
(data: { added: string[]; removed: string[]; roots: Array<{ name: string; dir: string }> }) => {
|
|
112
|
+
// Patch the cached tree synchronously before the refetch: drop the
|
|
113
|
+
// removed roots and take the event's (possibly re-disambiguated)
|
|
114
|
+
// names, so every consumer — the home page, the sidebar, the
|
|
115
|
+
// redirect below — is consistent right now rather than after the
|
|
116
|
+
// round trip. Added roots arrive with the refetch; there is no tree
|
|
117
|
+
// for them yet.
|
|
118
|
+
const removed = new Set(data.removed);
|
|
119
|
+
const nameByDir = new Map(data.roots.map((info) => [info.dir, info.name]));
|
|
120
|
+
queryClient.setQueriesData<RouterOutputs["getDocTree"]>(
|
|
121
|
+
trpc.getDocTree.queryFilter(),
|
|
122
|
+
(cached) =>
|
|
123
|
+
cached && {
|
|
124
|
+
...cached,
|
|
125
|
+
roots: cached.roots
|
|
126
|
+
.filter((root) => !removed.has(root.dir))
|
|
127
|
+
.map((root) => ({ ...root, name: nameByDir.get(root.dir) ?? root.name })),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
void queryClient.invalidateQueries({ ...trpc.getDocTree.queryFilter(), refetchType: "all" });
|
|
131
|
+
void queryClient.invalidateQueries({
|
|
132
|
+
...trpc.getFolderListing.queryFilter(),
|
|
133
|
+
type: "active",
|
|
134
|
+
});
|
|
135
|
+
window.dispatchEvent(new CustomEvent("mdxserve:roots-changed", { detail: data }));
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
}
|