@karimsa/mdxserve 0.2.0 → 0.4.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 (57) hide show
  1. package/README.md +60 -10
  2. package/client/CodeBlock.tsx +24 -0
  3. package/client/DocView.tsx +9 -2
  4. package/client/ListingView.tsx +14 -10
  5. package/client/MarkdownTable.tsx +190 -0
  6. package/client/MdSection.tsx +56 -21
  7. package/client/MdSectionEditor.tsx +149 -2
  8. package/client/Mermaid.tsx +33 -7
  9. package/client/Settings.css +17 -0
  10. package/client/Settings.tsx +132 -0
  11. package/client/Table.tsx +2 -14
  12. package/client/app.css +339 -1
  13. package/client/block-state/README.md +39 -0
  14. package/client/block-state/storage.ts +73 -0
  15. package/client/block-state/useBlockState.ts +120 -0
  16. package/client/builtins/DataTable.tsx +269 -0
  17. package/client/builtins/chart-data.ts +22 -10
  18. package/client/builtins/index.ts +10 -0
  19. package/client/data-table/ColumnMenu.tsx +168 -0
  20. package/client/data-table/ColumnOptions.tsx +141 -0
  21. package/client/data-table/TableCell.tsx +76 -0
  22. package/client/data-table/TableHeaderCell.tsx +94 -0
  23. package/client/data-table/model.ts +194 -0
  24. package/client/data-table/navigation.ts +34 -0
  25. package/client/data-table/table-state.ts +196 -0
  26. package/client/data-table/useColumnMenu.ts +84 -0
  27. package/client/data-table/useTableState.ts +36 -0
  28. package/client/design/base/prose.css +4 -0
  29. package/client/diagrams/Dialog.tsx +577 -0
  30. package/client/diagrams/EditDialog.tsx +67 -0
  31. package/client/diagrams/Preferences.tsx +213 -0
  32. package/client/diagrams/edit-block.ts +93 -0
  33. package/client/diagrams/edit-context.ts +4 -0
  34. package/client/diagrams/session-id.ts +8 -0
  35. package/client/diagrams/slash.ts +94 -0
  36. package/client/diagrams/source.ts +73 -0
  37. package/client/diagrams/style.css +599 -0
  38. package/client/entry.tsx +2 -0
  39. package/client/mdx-components-base.ts +2 -2
  40. package/client/router.ts +8 -1
  41. package/client/shell/AppShell.tsx +38 -15
  42. package/client/shell/Sidebar.tsx +20 -2
  43. package/client/shell/StandaloneShell.tsx +19 -6
  44. package/client/shell/TocRail.tsx +4 -3
  45. package/client/shell/TopBar.tsx +17 -5
  46. package/client/state.ts +14 -0
  47. package/client/ui/ConfirmDeleteDialog.tsx +48 -91
  48. package/client/ui/ExpandModal.tsx +63 -104
  49. package/client/ui/Modal.tsx +123 -0
  50. package/client/ui/ResizeHandle.tsx +1 -1
  51. package/client/ui/SearchDialog.tsx +81 -88
  52. package/client/ui/modal.css +42 -0
  53. package/dist/cli.js +2191 -778
  54. package/dist/diagram-worker.js +17 -0
  55. package/dist/registry.json +297 -0
  56. package/package.json +42 -2
  57. package/skills/mdxserve/SKILL.md +56 -1
@@ -1,4 +1,21 @@
1
- import { useEffect, useState, type KeyboardEvent, type MouseEvent } from "react";
1
+ import {
2
+ findDiagram,
3
+ replaceDiagram,
4
+ mermaidEditButtons,
5
+ type DiagramEditTarget,
6
+ } from "./diagrams/edit-block";
7
+ import type { ExistingDiagram } from "./diagrams/edit-context";
8
+ import { mermaidSlash } from "./diagrams/slash";
9
+ import { MermaidDialog } from "./diagrams/Dialog";
10
+ import type { Range } from "@tiptap/core";
11
+ import {
12
+ useEffect,
13
+ useState,
14
+ useRef,
15
+ useCallback,
16
+ type KeyboardEvent,
17
+ type MouseEvent,
18
+ } from "react";
2
19
  import type { Editor } from "@tiptap/core";
3
20
  import type { Mark } from "@tiptap/pm/model";
4
21
  import { EditorContent, useEditor } from "@tiptap/react";
@@ -7,7 +24,7 @@ import { TaskItem, TaskList } from "@tiptap/extension-list";
7
24
  import { TableKit } from "@tiptap/extension-table";
8
25
  import { Image } from "@tiptap/extension-image";
9
26
  import { Markdown } from "@tiptap/markdown";
10
- import { motion } from "framer-motion";
27
+ import { AnimatePresence, motion } from "framer-motion";
11
28
  import { isTRPCClientError } from "@trpc/client";
12
29
  import { trpcClient } from "./api";
13
30
  import { TRANSITIONS } from "./motion";
@@ -25,6 +42,7 @@ const hint = {
25
42
  };
26
43
 
27
44
  export interface MdSectionEditorProps {
45
+ initialDiagram?: ExistingDiagram;
28
46
  /** Raw markdown for exactly [startLine, endLine], sliced by MdSection.tsx. */
29
47
  source: string;
30
48
  /** Version token for the whole file at the moment `source` was fetched — the save's stale-write guard. */
@@ -81,6 +99,7 @@ function unwrapSoftBreaks(editor: Editor): void {
81
99
  * `@tiptap/extension-link` install is needed to configure `link`.
82
100
  */
83
101
  export default function MdSectionEditor({
102
+ initialDiagram,
84
103
  source,
85
104
  version,
86
105
  path,
@@ -89,6 +108,39 @@ export default function MdSectionEditor({
89
108
  onDone,
90
109
  }: MdSectionEditorProps) {
91
110
  const [saving, setSaving] = useState(false);
111
+ const diagramEnabled = useRef(false);
112
+ const [diagramReady, setDiagramReady] = useState(false);
113
+ const [editTarget, setEditTarget] = useState<DiagramEditTarget | null>(null);
114
+ const openedDiagram = useRef(false);
115
+ const [diagramRange, setDiagramRange] = useState<Range | null>(null);
116
+ const openDiagram = useRef<(range: Range) => void>(() => {});
117
+ openDiagram.current = setDiagramRange;
118
+ useEffect(() => {
119
+ let active = true;
120
+ const refresh = () => {
121
+ void trpcClient.getDiagramPreferences
122
+ .query({})
123
+ .then((settings) => {
124
+ if (active) {
125
+ diagramEnabled.current = settings.agent !== "disabled";
126
+ setDiagramReady(diagramEnabled.current);
127
+ if (!diagramEnabled.current) {
128
+ setDiagramRange(null);
129
+ setEditTarget(null);
130
+ }
131
+ }
132
+ })
133
+ .catch(() => {
134
+ diagramEnabled.current = false;
135
+ });
136
+ };
137
+ refresh();
138
+ window.addEventListener("mdxserve-diagram-preferences", refresh);
139
+ return () => {
140
+ active = false;
141
+ window.removeEventListener("mdxserve-diagram-preferences", refresh);
142
+ };
143
+ }, []);
92
144
  // Drives the hints' entrance. Not framer's `initial`/`animate` on mount:
93
145
  // this component mounts through Suspense inside a transition, and its
94
146
  // first commit can happen while the subtree is still hidden, so a
@@ -99,6 +151,11 @@ export default function MdSectionEditor({
99
151
 
100
152
  const editor = useEditor({
101
153
  extensions: [
154
+ mermaidEditButtons(() => diagramEnabled.current, setEditTarget),
155
+ mermaidSlash(
156
+ () => diagramEnabled.current,
157
+ (range) => openDiagram.current(range),
158
+ ),
102
159
  StarterKit.configure({
103
160
  heading: { levels: [1, 2, 3, 4, 5, 6] },
104
161
  link: { openOnClick: false },
@@ -131,6 +188,44 @@ export default function MdSectionEditor({
131
188
  setEntered(true);
132
189
  }, [editor]);
133
190
 
191
+ useEffect(() => {
192
+ if (!editor || editor.isDestroyed || !initialDiagram || openedDiagram.current) return;
193
+ openedDiagram.current = true;
194
+ const target = findDiagram(editor, initialDiagram);
195
+ if (target) setEditTarget(target);
196
+ else
197
+ pushToast({
198
+ tone: "warn",
199
+ text: "Diagram changed on disk. Reopen the section before editing.",
200
+ });
201
+ }, [editor, initialDiagram]);
202
+
203
+ useEffect(() => {
204
+ if (!editor || editor.isDestroyed || !diagramReady || openedDiagram.current) return;
205
+ openedDiagram.current = true;
206
+ const document = editor.state.doc;
207
+ const block = document.firstChild;
208
+ // Tiptap adds an empty trailing paragraph after a code block.
209
+ const onlyDiagram =
210
+ document.childCount === 1 ||
211
+ (document.childCount === 2 &&
212
+ document.lastChild?.type.name === "paragraph" &&
213
+ document.lastChild.content.size === 0);
214
+ if (onlyDiagram && block?.type.name === "codeBlock" && block.attrs.language === "mermaid") {
215
+ setEditTarget({ range: { from: 0, to: block.nodeSize }, source: block.textContent });
216
+ }
217
+ }, [editor, diagramReady]);
218
+
219
+ useEffect(() => {
220
+ if (editor && !editor.isDestroyed) editor.view.dispatch(editor.state.tr);
221
+ }, [editor, diagramReady]);
222
+
223
+ const closeDiagram = useCallback(() => {
224
+ setDiagramRange(null);
225
+ setEditTarget(null);
226
+ editor?.commands.focus();
227
+ }, [editor]);
228
+
134
229
  async function save() {
135
230
  if (!editor || saving) return;
136
231
  setSaving(true);
@@ -187,6 +282,7 @@ export default function MdSectionEditor({
187
282
  }
188
283
 
189
284
  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
285
+ if (diagramRange || editTarget) return;
190
286
  if ((event.metaKey || event.ctrlKey) && event.key === "s") {
191
287
  event.preventDefault();
192
288
  void save();
@@ -207,6 +303,57 @@ export default function MdSectionEditor({
207
303
  return (
208
304
  <div className="mdx-section" data-editing onClick={onClick} onKeyDown={onKeyDown}>
209
305
  <EditorContent editor={editor} />
306
+ <AnimatePresence>
307
+ {(diagramRange || editTarget) && (
308
+ <MermaidDialog
309
+ initialSource={editTarget?.source}
310
+ onClose={closeDiagram}
311
+ onInsert={(source) => {
312
+ if (editTarget) {
313
+ if (!replaceDiagram(editor, editTarget, source)) {
314
+ pushToast({
315
+ tone: "warn",
316
+ text: "Diagram changed. Reopen it before applying changes.",
317
+ });
318
+ return;
319
+ }
320
+ closeDiagram();
321
+ return;
322
+ }
323
+ if (!diagramRange) return;
324
+ if (diagramRange.to > editor.state.doc.content.size) {
325
+ closeDiagram();
326
+ return;
327
+ }
328
+ const from = editor.state.doc.resolve(diagramRange.from);
329
+ if (
330
+ from.parent.type.name !== "paragraph" ||
331
+ from.parent.textContent !==
332
+ editor.state.doc.textBetween(diagramRange.from, diagramRange.to)
333
+ ) {
334
+ pushToast({
335
+ tone: "warn",
336
+ text: "Insertion location changed. Close the dialog and choose a new block.",
337
+ });
338
+ return;
339
+ }
340
+ editor
341
+ .chain()
342
+ .focus()
343
+ .insertContentAt(
344
+ { from: from.before(), to: from.after() },
345
+ {
346
+ type: "codeBlock",
347
+ attrs: { language: "mermaid" },
348
+ content: [{ type: "text", text: source }],
349
+ },
350
+ )
351
+ .run();
352
+ closeDiagram();
353
+ }}
354
+ />
355
+ )}
356
+ </AnimatePresence>
210
357
  {/* Keyboard is the only way out of edit mode (no buttons), so the hints
211
358
  float in the right gutter beside the frame, out of the text flow —
212
359
  the section keeps its read-mode box exactly. pointer-events-none so
@@ -131,7 +131,17 @@ function useThemeTick(): number {
131
131
  * mirrored into the expanded modal's header so view controls that live in the
132
132
  * code frame (the flow-direction toggle) stay reachable at full size.
133
133
  */
134
- export function MermaidDiagram({ source, toolbar }: { source: string; toolbar?: ReactNode }) {
134
+ export function MermaidDiagram({
135
+ source,
136
+ toolbar,
137
+ onRender,
138
+ fill = false,
139
+ }: {
140
+ source: string;
141
+ toolbar?: ReactNode;
142
+ onRender?: (source: string, valid: boolean) => void;
143
+ fill?: boolean;
144
+ }) {
135
145
  // Seeded from the source so a chart-diagram fence never shows the
136
146
  // "Rendering diagram…" placeholder — there is nothing to load for it.
137
147
  const [state, setState] = useState<State>(() => {
@@ -177,7 +187,10 @@ export function MermaidDiagram({ source, toolbar }: { source: string; toolbar?:
177
187
  return mermaid.render(renderId, source);
178
188
  })
179
189
  .then(({ svg }) => {
180
- if (!cancelled) setState({ kind: "ok", svg });
190
+ if (!cancelled) {
191
+ setState({ kind: "ok", svg });
192
+ onRender?.(source, true);
193
+ }
181
194
  })
182
195
  .catch((error: unknown) => {
183
196
  // mermaid.render() draws into a temporary `<div id="d<renderId>">` it
@@ -191,11 +204,12 @@ export function MermaidDiagram({ source, toolbar }: { source: string; toolbar?:
191
204
  if (cancelled) return;
192
205
  const message = error instanceof Error ? error.message : String(error);
193
206
  setState({ kind: "error", message });
207
+ onRender?.(source, false);
194
208
  });
195
209
  return () => {
196
210
  cancelled = true;
197
211
  };
198
- }, [source, id, themeTick]);
212
+ }, [source, id, themeTick, onRender]);
199
213
 
200
214
  return (
201
215
  <AnimatePresence mode="wait" initial={false}>
@@ -230,8 +244,12 @@ export function MermaidDiagram({ source, toolbar }: { source: string; toolbar?:
230
244
  </DiagramNoticeBody>
231
245
  </motion.div>
232
246
  ) : (
233
- <motion.div key="ok" {...VARIANTS.fade}>
234
- <ExpandableDiagram svg={state.svg} toolbar={toolbar} />
247
+ <motion.div key="ok" {...VARIANTS.fade} className={fill ? "h-full w-full" : undefined}>
248
+ {fill ? (
249
+ <PanZoomSvg svg={state.svg} viewportClassName="h-full" />
250
+ ) : (
251
+ <ExpandableDiagram svg={state.svg} toolbar={toolbar} />
252
+ )}
235
253
  </motion.div>
236
254
  )}
237
255
  </AnimatePresence>
@@ -384,6 +402,7 @@ function PanZoomSvg({
384
402
  */
385
403
  function ExpandableDiagram({ svg, toolbar }: { svg: string; toolbar?: ReactNode }) {
386
404
  const [expanded, setExpanded] = useState(false);
405
+ const [closing, setClosing] = useState(false);
387
406
  return (
388
407
  <>
389
408
  {/* Only one copy of the markup is live at a time. Mermaid doesn't namespace
@@ -392,7 +411,7 @@ function ExpandableDiagram({ svg, toolbar }: { svg: string; toolbar?: ReactNode
392
411
  SVG — the one svg-pan-zoom has already wrapped and transformed. The
393
412
  card sits behind the scrim while expanded, so the placeholder that
394
413
  holds its height never shows. */}
395
- {expanded ? (
414
+ {expanded || closing ? (
396
415
  <div className="h-96 w-full" aria-hidden="true" />
397
416
  ) : (
398
417
  <PanZoomSvg svg={svg} viewportClassName="h-96" onExpand={() => setExpanded(true)} />
@@ -401,7 +420,11 @@ function ExpandableDiagram({ svg, toolbar }: { svg: string; toolbar?: ReactNode
401
420
  open={expanded}
402
421
  svg={svg}
403
422
  toolbar={toolbar}
404
- onClose={() => setExpanded(false)}
423
+ onClose={() => {
424
+ setClosing(true);
425
+ setExpanded(false);
426
+ }}
427
+ onExitComplete={() => setClosing(false)}
405
428
  />
406
429
  </>
407
430
  );
@@ -412,16 +435,19 @@ function DiagramModal({
412
435
  svg,
413
436
  toolbar,
414
437
  onClose,
438
+ onExitComplete,
415
439
  }: {
416
440
  open: boolean;
417
441
  svg: string;
418
442
  toolbar?: ReactNode;
419
443
  onClose: () => void;
444
+ onExitComplete: () => void;
420
445
  }) {
421
446
  return (
422
447
  <ExpandModal
423
448
  open={open}
424
449
  onClose={onClose}
450
+ onExitComplete={onExitComplete}
425
451
  icon="image"
426
452
  title="Diagram"
427
453
  hint="Drag to pan · scroll to zoom"
@@ -0,0 +1,17 @@
1
+ @media (max-width: 639px) {
2
+ .diagram-settings .diagram-settings-body section {
3
+ padding: 20px;
4
+ }
5
+
6
+ .diagram-settings .diagram-setting-row {
7
+ flex-direction: column;
8
+ align-items: stretch;
9
+ gap: 10px;
10
+ }
11
+
12
+ .diagram-settings .diagram-setting-row .diagram-agent-select,
13
+ .diagram-settings .diagram-setting-row select {
14
+ width: 100%;
15
+ min-width: 0;
16
+ }
17
+ }
@@ -0,0 +1,132 @@
1
+ import { useAtom } from "jotai";
2
+ import { useState, type ReactNode } from "react";
3
+ import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.mjs";
4
+ import Network from "lucide-react/dist/esm/icons/network.mjs";
5
+ import PanelsTopLeft from "lucide-react/dist/esm/icons/panels-top-left.mjs";
6
+ import SettingsIcon from "lucide-react/dist/esm/icons/settings.mjs";
7
+ import CloseIcon from "lucide-react/dist/esm/icons/x.mjs";
8
+ import { contentLayoutAtom, tocVisibleAtom, type ContentLayout } from "./state";
9
+ import { Modal } from "./ui/Modal";
10
+
11
+ export function Settings({
12
+ onClose,
13
+ diagrams,
14
+ status,
15
+ saving = false,
16
+ }: {
17
+ onClose: () => void;
18
+ diagrams?: ReactNode;
19
+ status?: ReactNode;
20
+ saving?: boolean;
21
+ }) {
22
+ const [category, setCategory] = useState<"diagrams" | "layout">(diagrams ? "diagrams" : "layout");
23
+ const [layout, setLayout] = useAtom(contentLayoutAtom);
24
+ const [tocVisible, setTocVisible] = useAtom(tocVisibleAtom);
25
+ return (
26
+ <Modal
27
+ open
28
+ onClose={onClose}
29
+ dismissible={!saving}
30
+ className="diagram-dialog diagram-settings"
31
+ aria-label="Settings"
32
+ >
33
+ <header className="diagram-navbar">
34
+ <div className="diagram-title">
35
+ <SettingsIcon size={17} />
36
+ <h2>Settings</h2>
37
+ </div>
38
+ <button
39
+ type="button"
40
+ className="diagram-icon-button"
41
+ aria-label="Close settings"
42
+ disabled={saving}
43
+ onClick={onClose}
44
+ >
45
+ <CloseIcon size={18} />
46
+ </button>
47
+ </header>
48
+ <div className="diagram-settings-body">
49
+ <aside>
50
+ <button
51
+ type="button"
52
+ className="diagram-settings-category"
53
+ aria-pressed={category === "layout"}
54
+ onClick={() => setCategory("layout")}
55
+ >
56
+ <PanelsTopLeft size={15} />
57
+ Layout
58
+ </button>
59
+ {diagrams && (
60
+ <button
61
+ type="button"
62
+ className="diagram-settings-category"
63
+ aria-pressed={category === "diagrams"}
64
+ onClick={() => setCategory("diagrams")}
65
+ >
66
+ <Network size={15} />
67
+ Diagrams
68
+ </button>
69
+ )}
70
+ </aside>
71
+ {category === "layout" ? (
72
+ <section>
73
+ <h3>Layout</h3>
74
+ <p>Choose how content fills the available space.</p>
75
+ <label className="diagram-setting-row" htmlFor="content-layout">
76
+ <span>
77
+ <strong>Content layout</strong>
78
+ <small>
79
+ {layout === "flexible"
80
+ ? "Drag the bars to resize content."
81
+ : "Content automatically fills the available width."}
82
+ </small>
83
+ </span>
84
+ <span className="diagram-agent-select">
85
+ <select
86
+ id="content-layout"
87
+ value={layout}
88
+ onChange={(event) => setLayout(event.target.value as ContentLayout)}
89
+ >
90
+ <option value="flexible">Flexible</option>
91
+ <option value="full-width">Full width</option>
92
+ </select>
93
+ <ChevronDown size={14} aria-hidden="true" />
94
+ </span>
95
+ </label>
96
+ <label className="diagram-setting-row" htmlFor="toc-visibility">
97
+ <span>
98
+ <strong>Table of contents</strong>
99
+ <small>Show the “On this page” navigation beside documents.</small>
100
+ </span>
101
+ <span className="diagram-agent-select">
102
+ <select
103
+ id="toc-visibility"
104
+ value={tocVisible ? "visible" : "hidden"}
105
+ onChange={(event) => setTocVisible(event.target.value === "visible")}
106
+ >
107
+ <option value="visible">Visible</option>
108
+ <option value="hidden">Hidden</option>
109
+ </select>
110
+ <ChevronDown size={14} aria-hidden="true" />
111
+ </span>
112
+ </label>
113
+ </section>
114
+ ) : (
115
+ diagrams
116
+ )}
117
+ </div>
118
+ <footer className="diagram-settings-footer">
119
+ <span>
120
+ {category === "layout" ? (
121
+ "Saved in this browser."
122
+ ) : (
123
+ <>
124
+ Also available through <code>mdxserve setup</code>
125
+ </>
126
+ )}
127
+ </span>
128
+ {status ?? <span role="status">Settings save automatically.</span>}
129
+ </footer>
130
+ </Modal>
131
+ );
132
+ }
package/client/Table.tsx CHANGED
@@ -1,14 +1,2 @@
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
- }
1
+ // Compatibility export; native Markdown tables use the shared DataTable renderer.
2
+ export { MarkdownTable as Table } from "./MarkdownTable";