@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
@@ -0,0 +1,213 @@
1
+ import { useEffect, useState } from "react";
2
+ import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.mjs";
3
+ import { Settings } from "../Settings";
4
+ import { trpcClient } from "../api";
5
+ export type AgentChoice = "codex" | "claude" | "disabled";
6
+ export function DiagramPreferences({
7
+ onClose,
8
+ canEditDiagrams = true,
9
+ }: {
10
+ onClose: () => void;
11
+ canEditDiagrams?: boolean;
12
+ }) {
13
+ return canEditDiagrams ? <DiagramSettings onClose={onClose} /> : <Settings onClose={onClose} />;
14
+ }
15
+
16
+ function DiagramSettings({ onClose }: { onClose: () => void }) {
17
+ const [agent, setAgent] = useState<AgentChoice>("disabled");
18
+ const [models, setModels] = useState({ codex: "", claude: "" });
19
+ const [catalog, setCatalog] = useState<{ id: string; label: string }[]>([]);
20
+ const [loadingModels, setLoadingModels] = useState(false);
21
+ const [modelError, setModelError] = useState("");
22
+ const [refreshModels, setRefreshModels] = useState(0);
23
+ const [statuses, setStatuses] = useState("Agents haven’t been checked yet.");
24
+ const [error, setError] = useState("");
25
+ const [pending, setPending] = useState(true);
26
+ const [checking, setChecking] = useState(false);
27
+ const [loaded, setLoaded] = useState(false);
28
+ useEffect(() => {
29
+ void trpcClient.getDiagramPreferences
30
+ .query({})
31
+ .then((result) => {
32
+ setAgent(result.agent);
33
+ setModels(result.models);
34
+ setLoaded(true);
35
+ })
36
+ .catch((failure) => setError(failure.message))
37
+ .finally(() => setPending(false));
38
+ }, []);
39
+ useEffect(() => {
40
+ let obsolete = false;
41
+ setCatalog([]);
42
+ setModelError("");
43
+ if (!loaded || agent === "disabled") {
44
+ setLoadingModels(false);
45
+ return;
46
+ }
47
+ setLoadingModels(true);
48
+ void trpcClient.listDiagramModels
49
+ .mutate({ provider: agent })
50
+ .then((result) => {
51
+ if (!obsolete) setCatalog(result);
52
+ })
53
+ .catch(() => {
54
+ if (!obsolete) setModelError("Couldn’t load models. Your saved choice is unchanged.");
55
+ })
56
+ .finally(() => {
57
+ if (!obsolete) setLoadingModels(false);
58
+ });
59
+ return () => {
60
+ obsolete = true;
61
+ };
62
+ }, [agent, loaded, refreshModels]);
63
+ async function save(nextAgent: AgentChoice, nextModels = models) {
64
+ const previousAgent = agent;
65
+ const previousModels = models;
66
+ setAgent(nextAgent);
67
+ setModels(nextModels);
68
+ setPending(true);
69
+ setError("");
70
+ try {
71
+ await trpcClient.setDiagramPreferences.mutate({ agent: nextAgent, models: nextModels });
72
+ window.dispatchEvent(new Event("mdxserve-diagram-preferences"));
73
+ } catch {
74
+ setAgent(previousAgent);
75
+ setModels(previousModels);
76
+ setError("Couldn’t save settings. Your previous settings are unchanged. Try again.");
77
+ } finally {
78
+ setPending(false);
79
+ }
80
+ }
81
+
82
+ return (
83
+ <Settings
84
+ onClose={onClose}
85
+ saving={(loaded && pending) || checking}
86
+ status={
87
+ error ? (
88
+ <p role="alert" className="diagram-error">
89
+ {error}
90
+ </p>
91
+ ) : (
92
+ <span role="status">
93
+ {pending ? (loaded ? "Saving…" : "Loading settings…") : "Settings save automatically."}
94
+ </span>
95
+ )
96
+ }
97
+ diagrams={
98
+ <section>
99
+ <h3>Diagram generation</h3>
100
+ <p>Turn descriptions and images into editable Mermaid.</p>
101
+ <label className="diagram-setting-row" htmlFor="diagram-agent">
102
+ <span>
103
+ <strong>Default agent</strong>
104
+ <small>Use an installed, signed-in CLI.</small>
105
+ </span>
106
+ <span className="diagram-agent-select">
107
+ <select
108
+ id="diagram-agent"
109
+ value={agent}
110
+ onChange={(event) => void save(event.target.value as AgentChoice)}
111
+ disabled={!loaded || pending || checking}
112
+ >
113
+ <option value="codex">Codex</option>
114
+ <option value="claude">Claude Code</option>
115
+ <option value="disabled">Disabled</option>
116
+ </select>
117
+ <ChevronDown size={14} aria-hidden="true" />
118
+ </span>
119
+ </label>
120
+ <p className="diagram-setting-note">
121
+ Auto detect selects an installed, signed-in agent for every conversion.
122
+ </p>
123
+ {agent !== "disabled" && (
124
+ <>
125
+ <label className="diagram-setting-row" htmlFor="diagram-model">
126
+ <span>
127
+ <strong>Model</strong>
128
+ <small>{agent === "codex" ? "Codex" : "Claude Code"} model for diagrams.</small>
129
+ </span>
130
+ <span className="diagram-agent-select diagram-model-select">
131
+ <select
132
+ id="diagram-model"
133
+ aria-label="Model"
134
+ value={models[agent]}
135
+ disabled={!loaded || loadingModels || pending || checking}
136
+ onChange={(event) =>
137
+ void save(agent, { ...models, [agent]: event.target.value })
138
+ }
139
+ >
140
+ {!catalog.some((model) => model.id === models[agent]) && (
141
+ <option value={models[agent]}>
142
+ {models[agent]}
143
+ {loadingModels ? "" : " (saved)"}
144
+ </option>
145
+ )}
146
+ {catalog.map((model) => (
147
+ <option key={model.id} value={model.id}>
148
+ {model.label}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ <ChevronDown size={14} aria-hidden="true" />
153
+ </span>
154
+ </label>
155
+ <div className="diagram-model-status">
156
+ <small role="status">
157
+ {loadingModels
158
+ ? "Loading models from CLI…"
159
+ : modelError || "Models from your installed CLI."}
160
+ </small>
161
+ <button
162
+ type="button"
163
+ disabled={loadingModels}
164
+ onClick={() => setRefreshModels((value) => value + 1)}
165
+ >
166
+ Refresh models
167
+ </button>
168
+ </div>
169
+ </>
170
+ )}
171
+ <div className="diagram-agent-status">
172
+ <span role="status">{statuses}</span>
173
+ <button
174
+ type="button"
175
+ disabled={checking || pending || !loaded}
176
+ onClick={async () => {
177
+ setChecking(true);
178
+ setStatuses("Checking agents…");
179
+ try {
180
+ const result = await trpcClient.probeDiagramAgents.mutate({});
181
+ const ready = result.filter((status) => status.state === "ready");
182
+ await save(
183
+ ready.find((status) => status.provider === agent)?.provider ??
184
+ ready[0]?.provider ??
185
+ "disabled",
186
+ );
187
+ setStatuses(
188
+ result
189
+ .map(
190
+ (status) =>
191
+ `${status.provider === "codex" ? "Codex" : "Claude Code"}: ${status.state}`,
192
+ )
193
+ .join(" · "),
194
+ );
195
+ } catch {
196
+ setStatuses("Could not check agents");
197
+ } finally {
198
+ setChecking(false);
199
+ }
200
+ }}
201
+ >
202
+ Auto detect
203
+ </button>
204
+ </div>
205
+ <p className="diagram-settings-disclosure">
206
+ Text and images are sent to the selected provider using your account. Disabled stops
207
+ conversion; existing diagrams still render.
208
+ </p>
209
+ </section>
210
+ }
211
+ />
212
+ );
213
+ }
@@ -0,0 +1,93 @@
1
+ import { comparableSource } from "./source";
2
+ import { Extension, type Editor, type Range } from "@tiptap/core";
3
+ import { Plugin } from "@tiptap/pm/state";
4
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
5
+ import { closeHistory } from "@tiptap/pm/history";
6
+ import type { ExistingDiagram } from "./edit-context";
7
+ export type DiagramEditTarget = { range: Range; source: string };
8
+ export function findDiagram(editor: Editor, target: ExistingDiagram): DiagramEditTarget | null {
9
+ let index = 0;
10
+ let found: DiagramEditTarget | null = null;
11
+ editor.state.doc.descendants((node, position) => {
12
+ if (node.type.name !== "codeBlock" || node.attrs.language !== "mermaid") return;
13
+ if (
14
+ index++ === target.index &&
15
+ comparableSource(node.textContent) === comparableSource(target.source)
16
+ )
17
+ found = { range: { from: position, to: position + node.nodeSize }, source: node.textContent };
18
+ });
19
+ return found;
20
+ }
21
+ /** Replace exactly the captured block, preserving its attributes and an independent undo step. */
22
+ export function replaceDiagram(editor: Editor, target: DiagramEditTarget, source: string): boolean {
23
+ if (target.range.from < 0 || target.range.from >= editor.state.doc.content.size) return false;
24
+ const node = editor.state.doc.nodeAt(target.range.from);
25
+ if (
26
+ !node ||
27
+ node.type.name !== "codeBlock" ||
28
+ node.attrs.language !== "mermaid" ||
29
+ node.textContent !== target.source ||
30
+ target.range.to !== target.range.from + node.nodeSize
31
+ )
32
+ return false;
33
+ editor.view.dispatch(closeHistory(editor.state.tr));
34
+ const replaced = editor.commands.insertContentAt(target.range, {
35
+ type: "codeBlock",
36
+ attrs: node.attrs,
37
+ content: source ? [{ type: "text", text: source }] : [],
38
+ });
39
+ editor.view.dispatch(closeHistory(editor.state.tr));
40
+ return replaced;
41
+ }
42
+ /** Keep the same edit action available while the section is already in Tiptap. */
43
+ export function mermaidEditButtons(
44
+ enabled: () => boolean,
45
+ onEdit: (target: DiagramEditTarget) => void,
46
+ ) {
47
+ return Extension.create({
48
+ name: "mermaidEditButtons",
49
+ addProseMirrorPlugins() {
50
+ return [
51
+ new Plugin({
52
+ props: {
53
+ decorations(state) {
54
+ if (!enabled()) return DecorationSet.empty;
55
+ const decorations: Decoration[] = [];
56
+ state.doc.descendants((node, position) => {
57
+ if (node.type.name !== "codeBlock" || node.attrs.language !== "mermaid") return;
58
+ decorations.push(
59
+ Decoration.widget(
60
+ position,
61
+ () => {
62
+ const button = document.createElement("button");
63
+ button.type = "button";
64
+ button.className = "diagram-edit-block";
65
+ button.textContent = "Edit diagram";
66
+ button.setAttribute("aria-label", "Edit diagram");
67
+ // Keep ProseMirror from moving the selection and replacing this widget before click.
68
+ button.onmousedown = (event) => {
69
+ event.preventDefault();
70
+ event.stopPropagation();
71
+ };
72
+ button.onclick = (event) => {
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ onEdit({
76
+ range: { from: position, to: position + node.nodeSize },
77
+ source: node.textContent,
78
+ });
79
+ };
80
+ return button;
81
+ },
82
+ { side: -1 },
83
+ ),
84
+ );
85
+ });
86
+ return DecorationSet.create(state.doc, decorations);
87
+ },
88
+ },
89
+ }),
90
+ ];
91
+ },
92
+ });
93
+ }
@@ -0,0 +1,4 @@
1
+ import { createContext } from "react";
2
+ export type ExistingDiagram = { index: number; source: string };
3
+ /** Only editable Markdown sections provide this; exports and standalone components do not. */
4
+ export const DiagramEditContext = createContext<((target: ExistingDiagram) => void) | null>(null);
@@ -0,0 +1,8 @@
1
+ /** getRandomValues works on plain HTTP LAN origins as well as secure contexts. */
2
+ export function sessionId(): string {
3
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
4
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
5
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
6
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
8
+ }
@@ -0,0 +1,94 @@
1
+ import { Extension, type Range } from "@tiptap/core";
2
+ import Suggestion from "@tiptap/suggestion";
3
+ import { PluginKey } from "@tiptap/pm/state";
4
+
5
+ export function mermaidSlash(enabled: () => boolean, onOpen: (range: Range) => void) {
6
+ return Extension.create({
7
+ name: "mermaidSlash",
8
+ addProseMirrorPlugins() {
9
+ return [
10
+ Suggestion({
11
+ editor: this.editor,
12
+ pluginKey: new PluginKey("mermaidSlash"),
13
+ char: "/",
14
+ startOfLine: true,
15
+ allow: ({ editor, state, range }) =>
16
+ enabled() &&
17
+ editor.isEditable &&
18
+ state.selection.empty &&
19
+ !state.selection.$from.marks().some((mark) => mark.type.name === "code") &&
20
+ state.doc.resolve(range.from).parent.type.name === "paragraph" &&
21
+ state.doc.resolve(range.from).parent.textContent ===
22
+ state.doc.textBetween(range.from, range.to),
23
+ items: ({ query }) =>
24
+ ["mermaid", "diagram", "er", "image"].some((term) =>
25
+ term.startsWith(query.toLowerCase()),
26
+ )
27
+ ? ["mermaid"]
28
+ : [],
29
+ command: ({ range }) => onOpen(range),
30
+ render: () => {
31
+ let menu: HTMLDivElement | undefined;
32
+ let choose: (() => void) | undefined;
33
+ return {
34
+ onStart(props) {
35
+ menu = document.createElement("div");
36
+ menu.className = "diagram-slash-menu";
37
+ menu.hidden = props.items.length === 0;
38
+ menu.setAttribute("role", "listbox");
39
+ menu.setAttribute("aria-label", "Insert block");
40
+ const button = document.createElement("button");
41
+ button.type = "button";
42
+ button.setAttribute("role", "option");
43
+ button.setAttribute("aria-selected", "true");
44
+ button.textContent = "Mermaid diagram";
45
+ button.onmousedown = (event) => {
46
+ event.preventDefault();
47
+ choose?.();
48
+ menu?.remove();
49
+ menu = undefined;
50
+ };
51
+ menu.append(button);
52
+ document.body.append(menu);
53
+ choose = () => props.command("mermaid");
54
+ const rect = props.clientRect?.();
55
+ if (rect) {
56
+ menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 260))}px`;
57
+ menu.style.top = `${Math.min(rect.bottom + 6, window.innerHeight - 70)}px`;
58
+ }
59
+ },
60
+ onUpdate(props) {
61
+ choose = () => props.command("mermaid");
62
+ if (menu) menu.hidden = props.items.length === 0;
63
+ },
64
+ onKeyDown({ event }) {
65
+ if (event.key === "Escape") {
66
+ menu?.remove();
67
+ menu = undefined;
68
+ event.preventDefault();
69
+ event.stopPropagation();
70
+ return true;
71
+ }
72
+ if (menu && !menu.hidden && ["Enter", "ArrowDown", "ArrowUp"].includes(event.key)) {
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ if (event.key === "Enter") {
76
+ choose?.();
77
+ menu.remove();
78
+ menu = undefined;
79
+ }
80
+ return true;
81
+ }
82
+ return false;
83
+ },
84
+ onExit() {
85
+ menu?.remove();
86
+ menu = undefined;
87
+ },
88
+ };
89
+ },
90
+ }),
91
+ ];
92
+ },
93
+ });
94
+ }
@@ -0,0 +1,73 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import type { Code, RootContent } from "mdast";
3
+ import type { ExistingDiagram } from "./edit-context";
4
+
5
+ // Syntax highlighting pads empty lines for display; those spaces aren't edits.
6
+ export function comparableSource(source: string): string {
7
+ return source
8
+ .replace(/\r\n/g, "\n")
9
+ .replace(/^[\t ]+$/gm, "")
10
+ .trim();
11
+ }
12
+
13
+ /** Locate the original fence without round-tripping the surrounding Markdown. */
14
+ export function locateDiagram(markdown: string, target: ExistingDiagram) {
15
+ const diagrams: Code[] = [];
16
+ function visit(node: RootContent): void {
17
+ if (node.type === "code" && node.lang === "mermaid") diagrams.push(node);
18
+ if ("children" in node) for (const child of node.children) visit(child as RootContent);
19
+ }
20
+ for (const node of fromMarkdown(markdown).children) visit(node);
21
+ const node = diagrams[target.index];
22
+ const start = node?.position?.start.offset;
23
+ const end = node?.position?.end.offset;
24
+ if (
25
+ !node ||
26
+ start === undefined ||
27
+ end === undefined ||
28
+ comparableSource(node.value) !== comparableSource(target.source)
29
+ )
30
+ return null;
31
+ const original = markdown.slice(start, end);
32
+ const opening = original.match(/^(`{3,}|~{3,})[^\n]*(?:\n|$)/);
33
+ if (!opening) return null;
34
+ const lastBreak = original.lastIndexOf("\n");
35
+ const lastLine = original.slice(lastBreak + 1);
36
+ const fence = opening[1];
37
+ const closeMatch = lastLine.match(/^([\t >]*)(`{3,}|~{3,})[\t ]*$/);
38
+ const hasClosing =
39
+ lastBreak >= 0 &&
40
+ closeMatch &&
41
+ closeMatch[2][0] === fence[0] &&
42
+ closeMatch[2].length >= fence.length;
43
+ // Markdown permits fences terminated by EOF or the end of their container.
44
+ // Retain container indentation when adding an explicit closing delimiter.
45
+ const lineStart = markdown.lastIndexOf("\n", start - 1) + 1;
46
+ const openingPrefix = markdown.slice(lineStart, start);
47
+ const prefix = hasClosing
48
+ ? closeMatch[1]
49
+ : openingPrefix.replace(/(?:[-+*]|\d+[.)])([ \t]+)/g, (marker) => " ".repeat(marker.length));
50
+ const closing = hasClosing ? lastLine : prefix + fence;
51
+ const openingLine = opening[0].endsWith("\n") ? opening[0] : opening[0] + "\n";
52
+ const trailingNewline = !hasClosing && original.endsWith("\n") ? "\n" : "";
53
+ return {
54
+ source: node.value,
55
+ replace(source: string) {
56
+ const body = source
57
+ .replace(/\r\n/g, "\n")
58
+ .trimEnd()
59
+ .split("\n")
60
+ .map((line) => prefix + line)
61
+ .join("\n");
62
+ return (
63
+ markdown.slice(0, start) +
64
+ openingLine +
65
+ body +
66
+ "\n" +
67
+ closing +
68
+ trailingNewline +
69
+ markdown.slice(end)
70
+ );
71
+ },
72
+ };
73
+ }