@karimsa/mdxserve 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +233 -2
- package/client/App.tsx +7 -0
- package/client/CodeBlock.tsx +395 -0
- package/client/CrossFade.tsx +72 -0
- package/client/DocContext.ts +14 -0
- package/client/DocView.tsx +107 -0
- package/client/ErrorBox.tsx +23 -0
- package/client/Heading.tsx +31 -0
- package/client/HomeEmptyState.tsx +101 -0
- package/client/HomeView.tsx +78 -0
- package/client/ListingView.tsx +663 -0
- package/client/MdSection.tsx +234 -0
- package/client/MdSectionEditor.tsx +233 -0
- package/client/Mermaid.tsx +435 -0
- package/client/RenderErrorBoundary.tsx +40 -0
- package/client/Table.tsx +14 -0
- package/client/TaskCheckbox.tsx +38 -0
- package/client/api.ts +138 -0
- package/client/app.css +372 -0
- package/client/builtins/Badge.tsx +109 -0
- package/client/builtins/Button.tsx +111 -0
- package/client/builtins/Callout.tsx +97 -0
- package/client/builtins/Card.tsx +111 -0
- package/client/builtins/Chart.tsx +875 -0
- package/client/builtins/Diff.tsx +722 -0
- package/client/builtins/Dropdown.tsx +417 -0
- package/client/builtins/FileTree.tsx +87 -0
- package/client/builtins/Kbd.tsx +18 -0
- package/client/builtins/Screenshot.tsx +209 -0
- package/client/builtins/Sparkline.tsx +63 -0
- package/client/builtins/Tabs.tsx +169 -0
- package/client/builtins/Tooltip.tsx +52 -0
- package/client/builtins/chart-data.ts +133 -0
- package/client/builtins/index.ts +167 -0
- package/client/design/base/editor.css +151 -0
- package/client/design/base/prose.css +143 -0
- package/client/design/base/reset.css +79 -0
- package/client/design/tokens/colors.css +188 -0
- package/client/design/tokens/elevation.css +42 -0
- package/client/design/tokens/fonts.css +6 -0
- package/client/design/tokens/motion.css +76 -0
- package/client/design/tokens/spacing.css +34 -0
- package/client/design/tokens/typography.css +56 -0
- package/client/doc-module-cache.ts +17 -0
- package/client/editor-link.ts +27 -0
- package/client/entry.tsx +51 -0
- package/client/export-doc.ts +80 -0
- package/client/export-save.ts +96 -0
- package/client/favicon.svg +1 -0
- package/client/file-system-access.d.ts +29 -0
- package/client/format.ts +17 -0
- package/client/hooks.ts +34 -0
- package/client/lucide-icons.d.ts +9 -0
- package/client/mdx-components-base.ts +32 -0
- package/client/mdx-components.ts +18 -0
- package/client/mermaid-chart.ts +109 -0
- package/client/mermaid-direction.ts +73 -0
- package/client/motion.ts +104 -0
- package/client/platform.ts +16 -0
- package/client/route-path.ts +15 -0
- package/client/router.ts +452 -0
- package/client/shell/AppShell.tsx +401 -0
- package/client/shell/Footer.tsx +33 -0
- package/client/shell/NotFoundView.tsx +22 -0
- package/client/shell/Sidebar.tsx +169 -0
- package/client/shell/StandaloneShell.tsx +65 -0
- package/client/shell/TocRail.tsx +53 -0
- package/client/shell/TopBar.tsx +117 -0
- package/client/shell/use-doc-width.ts +61 -0
- package/client/shell/useToc.ts +77 -0
- package/client/ssr-entry.tsx +22 -0
- package/client/standalone-entry.tsx +51 -0
- package/client/state.ts +90 -0
- package/client/theme.ts +65 -0
- package/client/ui/Breadcrumb.tsx +49 -0
- package/client/ui/ConfirmDeleteDialog.tsx +113 -0
- package/client/ui/ExpandModal.tsx +342 -0
- package/client/ui/Icon.tsx +114 -0
- package/client/ui/IconButton.tsx +63 -0
- package/client/ui/Kbd.tsx +17 -0
- package/client/ui/PageNav.tsx +77 -0
- package/client/ui/ResizeHandle.tsx +201 -0
- package/client/ui/SearchDialog.tsx +187 -0
- package/client/ui/Tag.tsx +44 -0
- package/client/ui/Toast.tsx +189 -0
- package/client/ui/TocList.tsx +71 -0
- package/client/ui/icon-set.ts +102 -0
- package/client/ui/toast-count.ts +28 -0
- package/dist/cli.js +5091 -0
- package/dist/registry.json +703 -0
- package/dist/render-worker.js +145 -0
- package/package.json +113 -5
- package/skills/mdxserve/SKILL.md +178 -0
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
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="client/favicon.svg" alt="mdxserve logo" width="96" height="96" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
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
|
+

|
|
24
|
+
|
|
25
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
}
|