@karimsa/mdxserve 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karim Alibhai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,234 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="client/favicon.svg" alt="mdxserve logo" width="96" height="96" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">mdxserve</h1>
6
+
7
+ <p align="center">The prettiest way to view Markdown and MDX.</p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/@karimsa/mdxserve">
11
+ <img src="https://img.shields.io/npm/v/@karimsa/mdxserve" alt="npm version" />
12
+ </a>
13
+ <a href="LICENSE">
14
+ <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" />
15
+ </a>
16
+ </p>
17
+
18
+ Point it at a folder of `.md` / `.mdx` files and it serves them as a site you can browse: a
19
+ listing of every folder, each file rendered as a page with syntax-highlighted code, mermaid
20
+ diagrams, and a set of builtin components, in light and dark. Save a file and the open page
21
+ updates. Write JSX in an `.mdx` file and it renders too.
22
+
23
+ ![A rendered Markdown page in the light theme: sidebar file tree, prose column, table of contents](docs/screenshots/reader-light.jpg)
24
+
25
+ ![The same page in the dark theme](docs/screenshots/reader-dark.jpg)
26
+
27
+ It is built for folders of Markdown that already exist — notes, a `docs/` directory, plan
28
+ files an AI agent wrote — rather than for publishing a site. There is no build step and no
29
+ config file: run it, open the URL, read.
30
+
31
+ ## Install
32
+
33
+ You need Node 22.12 or newer.
34
+
35
+ ```bash
36
+ npm install -g @karimsa/mdxserve
37
+ mdxserve setup # optional: installs the writing skill for Claude Code and Codex
38
+ ```
39
+
40
+ `mdxserve setup` copies the agent writing skill into your Claude Code and Codex skill folders
41
+ (through `npx skills add`) and removes the MCP registration that older versions created. It is
42
+ safe to re-run; run it again after upgrading so the installed skill matches the CLI.
43
+
44
+ ## Quick start
45
+
46
+ ```bash
47
+ mdxserve serve -w ~/notes # serve one folder
48
+ mdxserve serve -w ~/notes -w ~/work/docs # or several unrelated folders at once
49
+ ```
50
+
51
+ Open the URL it prints (`http://127.0.0.1:4040` by default). With one folder you land in its
52
+ listing; with several, the home page lists them.
53
+
54
+ One server runs per user, and you can change what it serves without restarting it:
55
+
56
+ ```bash
57
+ mdxserve roots add ~/work/docs # start serving another folder
58
+ mdxserve roots remove ~/notes # stop serving one
59
+ mdxserve roots list # what is being served right now
60
+ mdxserve status # pid, port, URL, and the folders served
61
+ ```
62
+
63
+ Folders you add this way last until the server stops. `mdxserve serve` with no `-w` starts
64
+ an empty server you can add folders to later.
65
+
66
+ | Flag | Default | Effect |
67
+ | ------------- | ----------- | --------------------------------------------------------- |
68
+ | `-w, --watch` | none | Serve this folder; repeat the flag for more than one |
69
+ | `-p, --port` | `4040` | Port to listen on; falls back to a free one if it's taken |
70
+ | `--host` | `127.0.0.1` | Interface to bind; `0.0.0.0` exposes it on your LAN |
71
+
72
+ ## Writing docs
73
+
74
+ Any folder of Markdown works as-is. `.md` and `.mdx` are treated identically: both get
75
+ GitHub-flavored Markdown (tables, task lists, strikethrough), syntax-highlighted code blocks,
76
+ and mermaid diagrams. In an `.mdx` file you can also use JSX, `export` values, and `import`
77
+ your own React components.
78
+
79
+ Code fences take a title, a line-number gutter, and highlighted lines on the opening line:
80
+
81
+ ````md
82
+ ```ts title="src/server.ts" showLineNumbers {3-4}
83
+ const server = http.createServer(handler);
84
+ server.listen(port);
85
+ // these two lines are highlighted
86
+ console.log(`http://localhost:${port}`);
87
+ ```
88
+ ````
89
+
90
+ A ` ```mermaid ` fence renders as a pan-and-zoom diagram with a Diagram / Code toggle:
91
+
92
+ ````md
93
+ ```mermaid
94
+ flowchart LR
95
+ Browser -->|GET /notes/intro.md| Server
96
+ Server -->|compile via Vite| Page[Rendered page]
97
+ ```
98
+ ````
99
+
100
+ ![Mermaid diagrams rendered in the dark theme](docs/screenshots/diagrams-dark.jpg)
101
+
102
+ ### Builtin components
103
+
104
+ A small set of components is available in every file, `.md` included, with no import:
105
+
106
+ | Component | For |
107
+ | ------------------- | -------------------------------------------------------- |
108
+ | `Callout` | A note, tip, or warning the reader must not miss |
109
+ | `Tabs` / `Tab` | Per-OS or per-language variants of the same instructions |
110
+ | `Badge` | A status or tag inline with text |
111
+ | `Tooltip` | A short explanation on hover |
112
+ | `Button` | A link styled as a call to action |
113
+ | `Diff` | A before/after code change, unified or side by side |
114
+ | `Card` / `CardGrid` | Linked cards laid out in a grid |
115
+ | `Kbd` | A keyboard shortcut |
116
+ | `FileTree` | The shape of a directory |
117
+ | `Chart` | A bar (vertical or horizontal), line, area, or histogram |
118
+ | `Sparkline` | A tiny inline trend line |
119
+ | `Dropdown` | A picker, or a switcher between longer panels of content |
120
+ | `Screenshot` | An app or UI screenshot framed as a macOS window |
121
+
122
+ ```mdx
123
+ <Callout tone="warn" title="Before you deploy">
124
+ Rotate the key first; the old one stops working immediately.
125
+ </Callout>
126
+
127
+ <Tabs>
128
+ <Tab label="macOS">`brew install foo`</Tab>
129
+ <Tab label="Linux">`apt install foo`</Tab>
130
+ </Tabs>
131
+
132
+ Status: <Badge tone="ok" dot>Online</Badge>
133
+ ```
134
+
135
+ ![Chart, Sparkline and Dropdown builtins in the light theme](docs/screenshots/builtins-light.jpg)
136
+
137
+ Every component's props are documented from the command line:
138
+
139
+ ```bash
140
+ mdxserve components search # list them all
141
+ mdxserve components show Callout # props, defaults, and when to use it
142
+ ```
143
+
144
+ ### Your own components
145
+
146
+ An `.mdx` file can `import` a `.tsx` / `.jsx` component from next to it and use it as JSX.
147
+ Imports resolve relative to the file, like any other Vite import, and Tailwind utility classes
148
+ work anywhere. A local import with the same name as a builtin wins.
149
+
150
+ ```mdx
151
+ import { Counter } from "./components/Counter";
152
+
153
+ <Counter start={3} />
154
+ ```
155
+
156
+ ### The example folder
157
+
158
+ [`example/`](./example) is a working tour of everything above — plain Markdown, MDX, custom
159
+ components, Tailwind, every builtin, and a page of charts. From a clone, after `yarn`:
160
+
161
+ ```bash
162
+ yarn dev # serves ./example
163
+ ```
164
+
165
+ ## Reading and editing
166
+
167
+ Every page has the same shell: a sidebar listing the folder you are in, the document, and a
168
+ table of contents that follows you as you scroll. The sidebar and the content column are
169
+ resizable. Pages link to their previous and next neighbour and show which file they came from
170
+ and when it was last edited.
171
+
172
+ - `⌘K` / `Ctrl K` searches every served folder by title, heading, and body.
173
+ - The top bar toggles light and dark (it remembers your choice), prints, and exports.
174
+ - Double-click a section, or use its pencil, to edit it in place; `⌘S` saves,
175
+ `Esc` cancels. The edit is written back to the file on disk.
176
+ - Saving a file from your editor re-renders the open page, and a toast names what changed.
177
+ - In a folder listing, select files to move them to the OS Trash (never deleted outright).
178
+ - Diagrams and charts open full-screen; a flowchart's direction can be flipped without
179
+ touching the source.
180
+
181
+ Navigation is client-side — moving between folders and pages doesn't reload — and every
182
+ animation respects `prefers-reduced-motion`.
183
+
184
+ ## Exporting a page
185
+
186
+ `mdxserve export` turns one document into a single HTML file that opens from `file://` on any
187
+ machine, with the same theme, code blocks, diagrams, and components as the live viewer:
188
+
189
+ ```bash
190
+ mdxserve export docs/guide.md # writes ./guide.html
191
+ mdxserve export docs/guide.md -o ~/Desktop/guide.html
192
+ mdxserve export docs/guide.md --mermaid bundle # fully offline; inlines mermaid (~2.4 MB)
193
+ ```
194
+
195
+ The same export is in the viewer's top bar on any page. See
196
+ [`docs/advanced.md`](./docs/advanced.md#exporting) for exactly what the file does and doesn't
197
+ carry.
198
+
199
+ ## Using it with AI agents
200
+
201
+ mdxserve is a good place to read what an agent writes — plan files, reports, design docs —
202
+ and it gives the agent tools to write for it well:
203
+
204
+ - **A writing skill.** `skills/mdxserve/SKILL.md` teaches an agent to write Markdown that
205
+ renders richly here while staying plain and portable, and to check the builtin components
206
+ before using them. `mdxserve setup` installs it for Claude Code and Codex.
207
+ - **CLI verbs.** `mdxserve validate`, `search`, `docs`, `roots`, and `components` all take
208
+ `--json`, so an agent can check a file, find docs, and manage served folders from a shell.
209
+
210
+ `validate` works without a server (compile errors, unknown components and props) but only
211
+ renders the doc when an `mdxserve serve` is running.
212
+ [`docs/advanced.md`](./docs/advanced.md#the-agent-cli) covers the verbs in detail.
213
+
214
+ ## Keeping it running
215
+
216
+ `mdxserve serve` runs in the foreground. To keep it up after you close the terminal, use any
217
+ process manager — [oxmgr](https://github.com/Vladimir-Urik/OxMgr) is one option:
218
+
219
+ ```bash
220
+ oxmgr start "mdxserve serve"
221
+ mdxserve roots add ~/notes
222
+
223
+ oxmgr logs mdxserve # tail the server log
224
+ oxmgr stop mdxserve # stop it
225
+ ```
226
+
227
+ ## Going further
228
+
229
+ | Read | If you want to |
230
+ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
231
+ | [`docs/advanced.md`](./docs/advanced.md) | Understand the one-server-per-user model, URL and root rules, LAN exposure, the cache, exports in depth, and the agent CLI |
232
+ | [`docs/http-api.md`](./docs/http-api.md) | Call the server's typed HTTP API directly |
233
+ | [`docs/development.md`](./docs/development.md) | Work on mdxserve itself: build, test, add a builtin, and where things live |
234
+ | [`AGENTS.md`](./AGENTS.md) | The architecture rules the codebase follows (also what coding agents read) |
package/client/App.tsx ADDED
@@ -0,0 +1,7 @@
1
+ import { AppShell } from "./shell/AppShell";
2
+ import { useRouter, type Route } from "./router";
3
+
4
+ export function App({ initialRoute }: { initialRoute: Route }) {
5
+ const { route, navigate } = useRouter(initialRoute);
6
+ return <AppShell route={route} navigate={navigate} />;
7
+ }
@@ -0,0 +1,395 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ type ComponentPropsWithoutRef,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ } from "react";
14
+ import { AnimatePresence, motion } from "framer-motion";
15
+ import { MermaidDiagram } from "./Mermaid";
16
+ import {
17
+ readFlowchartDirection,
18
+ setFlowchartDirection,
19
+ type FlowDirection,
20
+ } from "./mermaid-direction";
21
+ import { useMediaQuery } from "./hooks";
22
+ import { DESKTOP_MEDIA } from "./platform";
23
+ import Dropdown from "./builtins/Dropdown";
24
+ import { TRANSITIONS } from "./motion";
25
+ import { CrossFade } from "./CrossFade";
26
+ import { Icon } from "./ui/Icon";
27
+
28
+ /**
29
+ * Code block chrome for MDX-compiled output, styled from the design tokens
30
+ * (bg-code-bg / border-code-border / text-code-fg — see client/design/tokens/colors.css).
31
+ *
32
+ * rehype-pretty-code (node_modules/rehype-pretty-code/dist/index.js) wraps every fenced
33
+ * code block as:
34
+ *
35
+ * <figure data-rehype-pretty-code-figure> // index.js:459
36
+ * <figcaption data-rehype-pretty-code-title // index.js:506-510
37
+ * data-language data-theme>title</figcaption> // only when `title="…"` meta is set
38
+ * <pre data-language data-theme> // index.js:478-479
39
+ * <code data-language data-theme style="display:grid" // index.js:483-484, 495-498
40
+ * data-line-numbers? // index.js:748, set on <code> when `showLineNumbers` meta present
41
+ * data-line-numbers-max-digits?> // index.js:499-500
42
+ * <span data-line data-highlighted-line?>…</span> // index.js:459(line loop)/766, "line" class -> data-line, {n,m-n} meta -> data-highlighted-line
43
+ * </code>
44
+ * </pre>
45
+ * </figure>
46
+ *
47
+ * We intercept both `figure` and `pre` in the MDXProvider components map (see
48
+ * client/entry.tsx). `Figure` renders the framed card (header bar + copy button)
49
+ * for processed code figures, pulling the title out of the figcaption and the
50
+ * language off the `pre`; `Pre` then renders as a bare, styled `<pre>` inside
51
+ * that card. A `<pre>` that is *not* inside one of our `Figure`s (e.g.
52
+ * hand-written JSX in an .mdx file, which bypasses rehype-pretty-code entirely)
53
+ * falls back to rendering the same card itself, so plain `<pre>` still looks right.
54
+ * `CodeFrame` is the single source of truth for that card, used by both paths;
55
+ * `CodeFrameHeader` is its header bar, exported standalone so other framed
56
+ * code-like surfaces (e.g. the Diff builtin) can reuse the same chrome.
57
+ */
58
+
59
+ const InsideCodeFrame = createContext(false);
60
+
61
+ /**
62
+ * The 34px header bar shared by every code-shaped frame: a mono filename/
63
+ * language label on the left, arbitrary actions (copy button, view toggle) on
64
+ * the right.
65
+ */
66
+ export function CodeFrameHeader({ label, actions }: { label?: ReactNode; actions?: ReactNode }) {
67
+ return (
68
+ <div className="flex h-[38px] items-center gap-3 border-b border-code-border pr-2.5 pl-4">
69
+ <span className="min-w-0 flex-1 truncate font-mono text-[length:var(--size-xs)] text-text-subtle">
70
+ {label}
71
+ </span>
72
+ {/* Each action is its own group; a hairline keeps a segmented control and
73
+ the copy button from reading as one cluster. */}
74
+ {actions ? (
75
+ <div className="flex items-center gap-3 [&>*+*]:border-l [&>*+*]:border-code-border [&>*+*]:pl-3">
76
+ {actions}
77
+ </div>
78
+ ) : null}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /** Copy-to-clipboard button with a crossfading copy/check icon and label. */
84
+ function CopyButton({ getText }: { getText: () => string }) {
85
+ const [copied, setCopied] = useState(false);
86
+
87
+ async function handleCopy() {
88
+ const text = getText();
89
+ if (!text) return;
90
+ try {
91
+ await navigator.clipboard.writeText(text);
92
+ setCopied(true);
93
+ setTimeout(() => setCopied(false), 1500);
94
+ } catch {
95
+ // clipboard unavailable; ignore
96
+ }
97
+ }
98
+
99
+ return (
100
+ <motion.button
101
+ type="button"
102
+ onClick={handleCopy}
103
+ layout
104
+ whileTap={{ scale: 0.94 }}
105
+ transition={TRANSITIONS.snap}
106
+ className={
107
+ "inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-sm px-2 font-sans text-[length:var(--size-xs)] font-medium leading-none transition-colors " +
108
+ (copied ? "text-text-accent" : "text-text-subtle hover:text-text-heading")
109
+ }
110
+ >
111
+ <AnimatePresence mode="wait" initial={false}>
112
+ <motion.span
113
+ key={copied ? "done" : "idle"}
114
+ initial={{ opacity: 0, y: -3 }}
115
+ animate={{ opacity: 1, y: 0 }}
116
+ exit={{ opacity: 0, y: 3 }}
117
+ transition={TRANSITIONS.fast}
118
+ className="inline-flex items-center gap-1.5"
119
+ >
120
+ <Icon name={copied ? "check" : "copy"} size={13} />
121
+ {copied ? "Copied" : "Copy"}
122
+ </motion.span>
123
+ </AnimatePresence>
124
+ </motion.button>
125
+ );
126
+ }
127
+
128
+ const DIRECTION_ICON: Record<FlowDirection, string> = {
129
+ TB: "arrow-down",
130
+ BT: "arrow-up",
131
+ LR: "arrow-right",
132
+ RL: "arrow-left",
133
+ };
134
+
135
+ const DIRECTION_LABEL: Record<FlowDirection, string> = {
136
+ TB: "Top to bottom",
137
+ BT: "Bottom to top",
138
+ LR: "Left to right",
139
+ RL: "Right to left",
140
+ };
141
+
142
+ /**
143
+ * Picks how a flowchart is laid out for the reader's view only: the source
144
+ * on disk, the code pane and the copy button all keep the author's direction.
145
+ * An icon-only trigger showing the current direction, opening the same menu
146
+ * the sidebar uses for sorting; the author's own direction is marked so the
147
+ * reader can always find the way back.
148
+ */
149
+ function DirectionMenu({
150
+ value,
151
+ authored,
152
+ onChange,
153
+ }: {
154
+ value: FlowDirection;
155
+ /** The direction written in the source. */
156
+ authored: FlowDirection;
157
+ onChange: (direction: FlowDirection) => void;
158
+ }) {
159
+ return (
160
+ <Dropdown
161
+ icon={DIRECTION_ICON[value]}
162
+ size="sm"
163
+ label="Layout"
164
+ value={value}
165
+ onChange={(next) => onChange(next as FlowDirection)}
166
+ options={(Object.keys(DIRECTION_LABEL) as FlowDirection[]).map((direction) => ({
167
+ value: direction,
168
+ label: DIRECTION_LABEL[direction],
169
+ description: direction === authored ? "As written" : undefined,
170
+ }))}
171
+ />
172
+ );
173
+ }
174
+
175
+ /** Diagram/Code segmented pill for mermaid frames; the active pill slides via `layoutId`. */
176
+ function ViewToggle({
177
+ view,
178
+ onChange,
179
+ toggleId,
180
+ }: {
181
+ view: "diagram" | "code";
182
+ onChange: (view: "diagram" | "code") => void;
183
+ toggleId: string;
184
+ }) {
185
+ return (
186
+ <div
187
+ role="tablist"
188
+ className="inline-flex gap-1 rounded-md border border-border-default bg-surface-sunken p-[3px]"
189
+ >
190
+ {(
191
+ [
192
+ { value: "diagram", icon: "image" },
193
+ { value: "code", icon: "code" },
194
+ ] as const
195
+ ).map((option) => (
196
+ <button
197
+ key={option.value}
198
+ type="button"
199
+ role="tab"
200
+ aria-selected={view === option.value}
201
+ onClick={() => onChange(option.value)}
202
+ className={
203
+ "relative inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-sm px-2.5 font-sans text-[length:var(--size-xs)] leading-none capitalize transition-colors " +
204
+ (view === option.value
205
+ ? "font-semibold text-text-heading"
206
+ : "font-medium text-text-subtle hover:text-text-heading")
207
+ }
208
+ >
209
+ {view === option.value ? (
210
+ <motion.span
211
+ layoutId={`${toggleId}-pill`}
212
+ transition={TRANSITIONS.snap}
213
+ className="absolute inset-0 rounded-sm border border-border-default bg-surface-card shadow-xs"
214
+ />
215
+ ) : null}
216
+ <span className="relative z-10 inline-flex items-center gap-1.5">
217
+ <Icon name={option.icon} size={12} />
218
+ {option.value}
219
+ </span>
220
+ </button>
221
+ ))}
222
+ </div>
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Shared card: header bar (label + actions) wrapping whatever `<pre>` is
228
+ * passed as children. Copies via a ref instead of prop-drilling so it works
229
+ * whether the `<pre>` came from `Figure` or was rendered directly by `Pre`.
230
+ * Content rises with the rest of the prose column, so the frame itself has no
231
+ * entrance animation of its own.
232
+ */
233
+ function CodeFrame({
234
+ title,
235
+ language,
236
+ children,
237
+ }: {
238
+ title?: string;
239
+ language?: string;
240
+ children: ReactNode;
241
+ }) {
242
+ const containerRef = useRef<HTMLDivElement>(null);
243
+ const isMermaid = language === "mermaid";
244
+ const [view, setView] = useState<"diagram" | "code">("diagram");
245
+ const [source, setSource] = useState<string | null>(null);
246
+ const label = title ?? language;
247
+ const showDiagram = isMermaid && view === "diagram";
248
+ const toggleId = useId();
249
+
250
+ // A flow-direction choice is a view setting, not an edit: it is applied to
251
+ // the text handed to mermaid and nowhere else. Remembering which source it
252
+ // was chosen for means a re-render with new content (the file was saved with
253
+ // a different header) falls back to the default automatically.
254
+ const wideScreen = useMediaQuery(DESKTOP_MEDIA, true);
255
+ const authored = source === null ? null : readFlowchartDirection(source);
256
+ // A left-to-right chart is wider than a phone column, so narrow screens start
257
+ // it top-down; vertical charts already fit and keep the author's direction.
258
+ const preferred =
259
+ authored !== null && !wideScreen && (authored === "LR" || authored === "RL") ? "TB" : authored;
260
+ const [chosen, setChosen] = useState<{ source: string; direction: FlowDirection } | null>(null);
261
+ const direction = chosen !== null && chosen.source === source ? chosen.direction : preferred;
262
+ const diagramSource =
263
+ source !== null && direction !== null ? setFlowchartDirection(source, direction) : source;
264
+ const directionMenu =
265
+ source !== null && authored !== null && direction !== null ? (
266
+ <DirectionMenu
267
+ value={direction}
268
+ authored={authored}
269
+ onChange={(next) => setChosen({ source, direction: next })}
270
+ />
271
+ ) : null;
272
+
273
+ // Pull the raw diagram text out of the (always-mounted) <pre> so the diagram
274
+ // view and the copy button share one source of truth. Select the code
275
+ // block's own <pre> (tagged by `Pre` below), not the first <pre> in the
276
+ // card: when a diagram fails, MermaidDiagram's error card puts the message
277
+ // in a <pre> that sits *before* the code pane, and a bare "pre" lookup
278
+ // would feed that message back into mermaid on the next re-render — each
279
+ // pass wrapping the last error, never recovering until a reload.
280
+ function codePre() {
281
+ return containerRef.current?.querySelector("pre[data-code-source]") ?? null;
282
+ }
283
+
284
+ useEffect(() => {
285
+ if (!isMermaid) return;
286
+ setSource(codePre()?.textContent ?? "");
287
+ // eslint-disable-next-line react-hooks/exhaustive-deps
288
+ }, [isMermaid, children]);
289
+
290
+ function getCopyText() {
291
+ return codePre()?.textContent ?? "";
292
+ }
293
+
294
+ return (
295
+ <div
296
+ ref={containerRef}
297
+ className="overflow-hidden rounded-lg border border-code-border bg-code-bg"
298
+ >
299
+ <CodeFrameHeader
300
+ label={label}
301
+ actions={
302
+ <>
303
+ {isMermaid ? <ViewToggle view={view} onChange={setView} toggleId={toggleId} /> : null}
304
+ {showDiagram ? directionMenu : null}
305
+ <CopyButton getText={getCopyText} />
306
+ </>
307
+ }
308
+ />
309
+ {isMermaid ? (
310
+ <CrossFade
311
+ active={showDiagram ? "diagram" : "code"}
312
+ panes={[
313
+ {
314
+ key: "diagram",
315
+ node:
316
+ diagramSource !== null ? (
317
+ <MermaidDiagram source={diagramSource} toolbar={directionMenu} />
318
+ ) : null,
319
+ },
320
+ {
321
+ key: "code",
322
+ node: <InsideCodeFrame.Provider value={true}>{children}</InsideCodeFrame.Provider>,
323
+ },
324
+ ]}
325
+ />
326
+ ) : (
327
+ <InsideCodeFrame.Provider value={true}>{children}</InsideCodeFrame.Provider>
328
+ )}
329
+ </div>
330
+ );
331
+ }
332
+
333
+ const PRE_CLASS =
334
+ "font-mono font-normal leading-[1.62] text-[length:var(--size-sm)] text-code-fg overflow-x-auto";
335
+
336
+ type PreProps = ComponentPropsWithoutRef<"pre"> & { "data-language"?: string };
337
+
338
+ /**
339
+ * MDXProvider `pre` override. Inside a `Figure`-rendered card it's just the bare
340
+ * styled `<pre>` (the card supplies the header/copy button); standalone, it wraps
341
+ * itself in `CodeFrame` so it still gets the full treatment.
342
+ */
343
+ export function Pre(props: PreProps) {
344
+ const insideFrame = useContext(InsideCodeFrame);
345
+ // `data-code-source` is what CodeFrame's source/copy lookups select on; both
346
+ // the in-figure and the standalone <pre> carry it.
347
+ if (insideFrame) {
348
+ return <pre {...props} data-code-source className={PRE_CLASS} />;
349
+ }
350
+ const { "data-language": language, ...rest } = props;
351
+ return (
352
+ <CodeFrame language={language}>
353
+ <pre {...rest} data-language={language} data-code-source className={PRE_CLASS} />
354
+ </CodeFrame>
355
+ );
356
+ }
357
+
358
+ type FigureProps = ComponentPropsWithoutRef<"figure"> & {
359
+ "data-rehype-pretty-code-figure"?: string;
360
+ };
361
+
362
+ function textContentOf(node: ReactNode): string {
363
+ if (typeof node === "string") return node;
364
+ if (typeof node === "number") return String(node);
365
+ if (Array.isArray(node)) return node.map(textContentOf).join("");
366
+ if (isValidElement(node)) return textContentOf((node.props as { children?: ReactNode }).children);
367
+ return "";
368
+ }
369
+
370
+ /**
371
+ * MDXProvider `figure` override. Only rehype-pretty-code's code figures (tagged
372
+ * with `data-rehype-pretty-code-figure`) get the framed treatment; any
373
+ * other `<figure>` (e.g. wrapping an image) renders unchanged.
374
+ */
375
+ export function Figure({ children, ...props }: FigureProps) {
376
+ if (!("data-rehype-pretty-code-figure" in props)) {
377
+ return <figure {...props}>{children}</figure>;
378
+ }
379
+
380
+ const nodes = Children.toArray(children).filter(isValidElement) as ReactElement<
381
+ Record<string, unknown>
382
+ >[];
383
+ const titleNode = nodes.find((node) => "data-rehype-pretty-code-title" in node.props);
384
+ // The figure only ever contains the optional title and the <pre>; avoid comparing
385
+ // component identity (it changes under Fast Refresh) and take the non-title child.
386
+ const preNode = nodes.find((node) => node !== titleNode);
387
+ const title = titleNode ? textContentOf(titleNode) : undefined;
388
+ const language = preNode ? (preNode.props["data-language"] as string | undefined) : undefined;
389
+
390
+ return (
391
+ <CodeFrame title={title} language={language}>
392
+ {preNode}
393
+ </CodeFrame>
394
+ );
395
+ }