@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.
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 +113 -5
  94. 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
+ }
@@ -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
+ }