@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.
- package/README.md +60 -10
- package/client/CodeBlock.tsx +24 -0
- package/client/DocView.tsx +9 -2
- package/client/ListingView.tsx +14 -10
- package/client/MarkdownTable.tsx +190 -0
- package/client/MdSection.tsx +56 -21
- package/client/MdSectionEditor.tsx +149 -2
- package/client/Mermaid.tsx +33 -7
- package/client/Settings.css +17 -0
- package/client/Settings.tsx +132 -0
- package/client/Table.tsx +2 -14
- package/client/app.css +339 -1
- package/client/block-state/README.md +39 -0
- package/client/block-state/storage.ts +73 -0
- package/client/block-state/useBlockState.ts +120 -0
- package/client/builtins/DataTable.tsx +269 -0
- package/client/builtins/chart-data.ts +22 -10
- package/client/builtins/index.ts +10 -0
- package/client/data-table/ColumnMenu.tsx +168 -0
- package/client/data-table/ColumnOptions.tsx +141 -0
- package/client/data-table/TableCell.tsx +76 -0
- package/client/data-table/TableHeaderCell.tsx +94 -0
- package/client/data-table/model.ts +194 -0
- package/client/data-table/navigation.ts +34 -0
- package/client/data-table/table-state.ts +196 -0
- package/client/data-table/useColumnMenu.ts +84 -0
- package/client/data-table/useTableState.ts +36 -0
- package/client/design/base/prose.css +4 -0
- package/client/diagrams/Dialog.tsx +577 -0
- package/client/diagrams/EditDialog.tsx +67 -0
- package/client/diagrams/Preferences.tsx +213 -0
- package/client/diagrams/edit-block.ts +93 -0
- package/client/diagrams/edit-context.ts +4 -0
- package/client/diagrams/session-id.ts +8 -0
- package/client/diagrams/slash.ts +94 -0
- package/client/diagrams/source.ts +73 -0
- package/client/diagrams/style.css +599 -0
- package/client/entry.tsx +2 -0
- package/client/mdx-components-base.ts +2 -2
- package/client/router.ts +8 -1
- package/client/shell/AppShell.tsx +38 -15
- package/client/shell/Sidebar.tsx +20 -2
- package/client/shell/StandaloneShell.tsx +19 -6
- package/client/shell/TocRail.tsx +4 -3
- package/client/shell/TopBar.tsx +17 -5
- package/client/state.ts +14 -0
- package/client/ui/ConfirmDeleteDialog.tsx +48 -91
- package/client/ui/ExpandModal.tsx +63 -104
- package/client/ui/Modal.tsx +123 -0
- package/client/ui/ResizeHandle.tsx +1 -1
- package/client/ui/SearchDialog.tsx +81 -88
- package/client/ui/modal.css +42 -0
- package/dist/cli.js +2191 -778
- package/dist/diagram-worker.js +17 -0
- package/dist/registry.json +297 -0
- package/package.json +42 -2
- package/skills/mdxserve/SKILL.md +56 -1
package/README.md
CHANGED
|
@@ -24,9 +24,9 @@ updates. Write JSX in an `.mdx` file and it renders too.
|
|
|
24
24
|
|
|
25
25
|

|
|
26
26
|
|
|
27
|
-
It
|
|
28
|
-
files an AI agent wrote
|
|
29
|
-
|
|
27
|
+
It works with folders of Markdown that already exist: notes, a `docs/` directory, or plan
|
|
28
|
+
files an AI agent wrote. For local use, there is no build step or config file: run it,
|
|
29
|
+
open the URL, and read. Use `--permissions restricted` to publish one folder as a read-only site.
|
|
30
30
|
|
|
31
31
|
## Install
|
|
32
32
|
|
|
@@ -41,6 +41,11 @@ mdxserve setup # optional: installs the writing skill for Claude Code and C
|
|
|
41
41
|
(through `npx skills add`) and removes the MCP registration that older versions created. It is
|
|
42
42
|
safe to re-run; run it again after upgrading so the installed skill matches the CLI.
|
|
43
43
|
|
|
44
|
+
Interactive commands check for updates in the background at most once a day and show
|
|
45
|
+
cached update notices on startup. Updates are never installed automatically. Disable
|
|
46
|
+
checks with `--no-update-notifier` or `NO_UPDATE_NOTIFIER=1`. CI, noninteractive commands,
|
|
47
|
+
and `--json` output stay quiet.
|
|
48
|
+
|
|
44
49
|
## Quick start
|
|
45
50
|
|
|
46
51
|
```bash
|
|
@@ -60,14 +65,59 @@ mdxserve roots list # what is being served right now
|
|
|
60
65
|
mdxserve status # pid, port, URL, and the folders served
|
|
61
66
|
```
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
Roots persist in `~/.mdxserve/config.json` (`$MDXSERVE_HOME/config.json` when set).
|
|
69
|
+
`mdxserve serve` restores them on restart; `-w` adds folders to the saved list.
|
|
70
|
+
You can also edit the file directly, including while the server is stopped:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"roots": ["~/notes", "~/work/docs"]
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The running server automatically reloads edits, updating open browsers and document access.
|
|
79
|
+
Relative paths resolve against the config directory; `~` resolves against your home directory.
|
|
80
|
+
Use `"roots": []` to unmount everything. Invalid JSON, missing directories, and invalid root
|
|
81
|
+
combinations leave the last working set active and log an error; fix the file to resume
|
|
82
|
+
reloading. Deleting the config while running also preserves the last working set until the
|
|
83
|
+
file is restored. An invalid config at startup reports an error. Root commands save changes
|
|
84
|
+
atomically and preserve unrelated settings. SQLite records only the live server's status;
|
|
85
|
+
the JSON file is the source of truth for which roots should exist.
|
|
86
|
+
|
|
87
|
+
| Flag | Default | Effect |
|
|
88
|
+
| --------------------------- | --------------- | --------------------------------------------------------- |
|
|
89
|
+
| `-w, --watch` | none | Serve this folder; repeat the flag for more than one |
|
|
90
|
+
| `-p, --port` | `4040` | Port to listen on; falls back to a free one if it's taken |
|
|
91
|
+
| `--host` | `127.0.0.1` | Interface to bind; determines default permission mode |
|
|
92
|
+
| `--dangerous-allow-network` | off | Authorize full permissions on a non-loopback interface |
|
|
93
|
+
| `--permissions <mode>` | interface-based | `full` on loopback, `restricted` on other interfaces |
|
|
94
|
+
|
|
95
|
+
## Hosting a public reader
|
|
96
|
+
|
|
97
|
+
Permission mode defaults to `full` on loopback and `restricted` on other interfaces.
|
|
98
|
+
The effective mode is printed at startup. Use `--permissions restricted` to select the
|
|
99
|
+
public reader restrictions explicitly, including behind a proxy connected over loopback:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
MDXSERVE_HOME=/tmp/mdxserve-public mdxserve serve --permissions restricted -w ./site --host 0.0.0.0 -p "${PORT:-4040}"
|
|
103
|
+
```
|
|
65
104
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
Restricted mode requires one `-w` directory. It ignores saved roots
|
|
106
|
+
and config changes. All HTTP mutations are denied, including editing, Trash, root changes,
|
|
107
|
+
exports, and diagram agents, even when a reverse proxy connects over loopback. The reader,
|
|
108
|
+
navigation, and search remain available. A restricted instance will not reuse another server
|
|
109
|
+
under the same `MDXSERVE_HOME`; give it its own directory.
|
|
110
|
+
|
|
111
|
+
Treat every file in `./site` as public. Visitors can fetch MDX source and visible assets,
|
|
112
|
+
including imported CSS and JSX. Vite also serves the mdxserve client and dependency modules
|
|
113
|
+
needed by the page. Hidden files, links to hidden targets, and links outside the served root
|
|
114
|
+
are blocked; links to visible files inside the root still work.
|
|
115
|
+
Run the server with a read-only site directory and no agent credentials. At the public proxy,
|
|
116
|
+
limit requests per client and concurrent requests to bound repeated Vite compilation and
|
|
117
|
+
search work.
|
|
118
|
+
Full permissions on a non-loopback interface require both `--permissions full` and
|
|
119
|
+
`--dangerous-allow-network`. The dangerous flag alone leaves the default restricted mode
|
|
120
|
+
in place. Use full network access only on a trusted network.
|
|
71
121
|
|
|
72
122
|
## Writing docs
|
|
73
123
|
|
package/client/CodeBlock.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DiagramEditContext } from "./diagrams/edit-context";
|
|
1
2
|
import {
|
|
2
3
|
Children,
|
|
3
4
|
createContext,
|
|
@@ -241,6 +242,7 @@ function CodeFrame({
|
|
|
241
242
|
}) {
|
|
242
243
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
243
244
|
const isMermaid = language === "mermaid";
|
|
245
|
+
const editDiagram = useContext(DiagramEditContext);
|
|
244
246
|
const [view, setView] = useState<"diagram" | "code">("diagram");
|
|
245
247
|
const [source, setSource] = useState<string | null>(null);
|
|
246
248
|
const label = title ?? language;
|
|
@@ -294,6 +296,7 @@ function CodeFrame({
|
|
|
294
296
|
return (
|
|
295
297
|
<div
|
|
296
298
|
ref={containerRef}
|
|
299
|
+
data-mermaid-editable={isMermaid ? "" : undefined}
|
|
297
300
|
className="overflow-hidden rounded-lg border border-code-border bg-code-bg"
|
|
298
301
|
>
|
|
299
302
|
<CodeFrameHeader
|
|
@@ -302,6 +305,27 @@ function CodeFrame({
|
|
|
302
305
|
<>
|
|
303
306
|
{isMermaid ? <ViewToggle view={view} onChange={setView} toggleId={toggleId} /> : null}
|
|
304
307
|
{showDiagram ? directionMenu : null}
|
|
308
|
+
{isMermaid && editDiagram && (
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
aria-label="Edit diagram"
|
|
312
|
+
data-print-hide
|
|
313
|
+
className="inline-flex items-center gap-1.5 text-[length:var(--size-xs)] text-text-subtle hover:text-text-heading"
|
|
314
|
+
onClick={() => {
|
|
315
|
+
const frame = containerRef.current;
|
|
316
|
+
const section = frame?.closest("[data-md-section]");
|
|
317
|
+
if (!frame || !section) return;
|
|
318
|
+
const index = [...section.querySelectorAll("[data-mermaid-editable]")].indexOf(
|
|
319
|
+
frame,
|
|
320
|
+
);
|
|
321
|
+
if (index >= 0) editDiagram({ index, source: getCopyText() });
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<Icon name="pencil" size={12} />
|
|
325
|
+
Edit
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
|
|
305
329
|
<CopyButton getText={getCopyText} />
|
|
306
330
|
</>
|
|
307
331
|
}
|
package/client/DocView.tsx
CHANGED
|
@@ -5,7 +5,13 @@ import { ErrorBox } from "./ErrorBox";
|
|
|
5
5
|
import { RenderErrorBoundary } from "./RenderErrorBoundary";
|
|
6
6
|
import type { DocModuleState } from "./doc-module-cache";
|
|
7
7
|
import { ResizeHandle } from "./ui/ResizeHandle";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
contentLayoutAtom,
|
|
10
|
+
DOC_MAX_WIDTH,
|
|
11
|
+
DOC_MIN_WIDTH,
|
|
12
|
+
docWidthAtom,
|
|
13
|
+
openSectionAtom,
|
|
14
|
+
} from "./state";
|
|
9
15
|
|
|
10
16
|
// Each Component a re-import produces (including an HMR re-import after a
|
|
11
17
|
// fix) is a distinct function identity, so this assigns it a stable, unique
|
|
@@ -45,6 +51,7 @@ export function DocView({
|
|
|
45
51
|
onRendered?: () => void;
|
|
46
52
|
}) {
|
|
47
53
|
const cached = module;
|
|
54
|
+
const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
|
|
48
55
|
const setWidth = useSetAtom(docWidthAtom);
|
|
49
56
|
// While a section is being edited the page width is pinned: a drag would
|
|
50
57
|
// reflow the editor under the caret, and the handles' hover strips sit
|
|
@@ -75,7 +82,7 @@ export function DocView({
|
|
|
75
82
|
const Content = cached.Component;
|
|
76
83
|
return (
|
|
77
84
|
<div ref={container} className="relative">
|
|
78
|
-
{!editing && (
|
|
85
|
+
{!editing && !fullWidth && (
|
|
79
86
|
<>
|
|
80
87
|
<ResizeHandle
|
|
81
88
|
side="left"
|
package/client/ListingView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AnimatePresence, motion } from "framer-motion";
|
|
2
|
-
import { useAtom, useSetAtom } from "jotai";
|
|
2
|
+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
3
3
|
import { useMutation } from "@tanstack/react-query";
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
5
5
|
import { queryClient, trpc } from "./api";
|
|
@@ -9,10 +9,11 @@ import { Icon } from "./ui/Icon";
|
|
|
9
9
|
import { ResizeHandle } from "./ui/ResizeHandle";
|
|
10
10
|
import { ConfirmDeleteDialog } from "./ui/ConfirmDeleteDialog";
|
|
11
11
|
import { pushToast } from "./ui/Toast";
|
|
12
|
-
import type
|
|
12
|
+
import { shellInfo, type ListingEntry } from "./router";
|
|
13
13
|
import Button from "./builtins/Button";
|
|
14
14
|
import Dropdown from "./builtins/Dropdown";
|
|
15
15
|
import {
|
|
16
|
+
contentLayoutAtom,
|
|
16
17
|
LISTING_MAX_WIDTH,
|
|
17
18
|
LISTING_MIN_WIDTH,
|
|
18
19
|
listingSortAtom,
|
|
@@ -371,6 +372,7 @@ export function ListingView({
|
|
|
371
372
|
}) {
|
|
372
373
|
const { path, rootDir, entries } = route;
|
|
373
374
|
const sidebar = mode === "sidebar";
|
|
375
|
+
const canDelete = shellInfo.permissions !== "restricted" && !sidebar;
|
|
374
376
|
const atRootTop = path === `${rootDir}/`;
|
|
375
377
|
const segments = path.split("/").filter(Boolean);
|
|
376
378
|
const parentSegments = segments.slice(0, -1);
|
|
@@ -389,6 +391,7 @@ export function ListingView({
|
|
|
389
391
|
[sorted, sidebar],
|
|
390
392
|
);
|
|
391
393
|
const setWidth = useSetAtom(listingWidthAtom);
|
|
394
|
+
const fullWidth = useAtomValue(contentLayoutAtom) === "full-width";
|
|
392
395
|
const container = useRef<HTMLDivElement>(null);
|
|
393
396
|
|
|
394
397
|
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
|
@@ -436,14 +439,14 @@ export function ListingView({
|
|
|
436
439
|
// consumed it, or the confirm dialog is up (it handles its own Escape).
|
|
437
440
|
// Sidebar mode has no selection to clear, so skip wiring the listener.
|
|
438
441
|
useEffect(() => {
|
|
439
|
-
if (
|
|
442
|
+
if (!canDelete) return;
|
|
440
443
|
function onKeyDown(event: globalThis.KeyboardEvent) {
|
|
441
444
|
if (event.defaultPrevented || confirmOpen) return;
|
|
442
445
|
if (event.key === "Escape" && selectionActive) setSelectedNames(new Set());
|
|
443
446
|
}
|
|
444
447
|
document.addEventListener("keydown", onKeyDown);
|
|
445
448
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
446
|
-
}, [
|
|
449
|
+
}, [canDelete, confirmOpen, selectionActive]);
|
|
447
450
|
|
|
448
451
|
const trashMutation = useMutation(
|
|
449
452
|
trpc.moveDocsToTrash.mutationOptions({
|
|
@@ -455,6 +458,7 @@ export function ListingView({
|
|
|
455
458
|
// `pending` and fire a duplicate request before React re-renders.
|
|
456
459
|
const pendingRef = useRef(false);
|
|
457
460
|
const handleConfirm = async () => {
|
|
461
|
+
if (!canDelete) return;
|
|
458
462
|
if (pendingRef.current) return;
|
|
459
463
|
pendingRef.current = true;
|
|
460
464
|
setPending(true);
|
|
@@ -497,7 +501,7 @@ export function ListingView({
|
|
|
497
501
|
|
|
498
502
|
return (
|
|
499
503
|
<div ref={container} className="relative">
|
|
500
|
-
{sidebar ? null : (
|
|
504
|
+
{sidebar || fullWidth ? null : (
|
|
501
505
|
<>
|
|
502
506
|
<ResizeHandle
|
|
503
507
|
side="left"
|
|
@@ -531,8 +535,8 @@ export function ListingView({
|
|
|
531
535
|
) : (
|
|
532
536
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
533
537
|
<div data-print-hide className="flex items-center gap-2">
|
|
534
|
-
<SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />
|
|
535
|
-
{selectionActive ? (
|
|
538
|
+
{canDelete && <SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />}
|
|
539
|
+
{canDelete && selectionActive ? (
|
|
536
540
|
<>
|
|
537
541
|
<span className="font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle tabular-nums">
|
|
538
542
|
{selected.length} selected
|
|
@@ -608,7 +612,7 @@ export function ListingView({
|
|
|
608
612
|
/>
|
|
609
613
|
);
|
|
610
614
|
}
|
|
611
|
-
const selection =
|
|
615
|
+
const selection = !canDelete
|
|
612
616
|
? {}
|
|
613
617
|
: {
|
|
614
618
|
selectable: true,
|
|
@@ -647,7 +651,7 @@ export function ListingView({
|
|
|
647
651
|
);
|
|
648
652
|
})}
|
|
649
653
|
</motion.div>
|
|
650
|
-
{
|
|
654
|
+
{canDelete ? (
|
|
651
655
|
<ConfirmDeleteDialog
|
|
652
656
|
open={confirmOpen}
|
|
653
657
|
files={selected}
|
|
@@ -657,7 +661,7 @@ export function ListingView({
|
|
|
657
661
|
}}
|
|
658
662
|
onConfirm={handleConfirm}
|
|
659
663
|
/>
|
|
660
|
-
)}
|
|
664
|
+
) : null}
|
|
661
665
|
</div>
|
|
662
666
|
);
|
|
663
667
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useId,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type TableHTMLAttributes,
|
|
8
|
+
} from "react";
|
|
9
|
+
import DataTable from "./builtins/DataTable";
|
|
10
|
+
import { inferColumn, parseBound, type Column, type Row } from "./data-table/model";
|
|
11
|
+
|
|
12
|
+
function elements(children: ReactNode) {
|
|
13
|
+
return Children.toArray(children).filter(
|
|
14
|
+
isValidElement<{
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
align?: string;
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
rowSpan?: number;
|
|
19
|
+
colSpan?: number;
|
|
20
|
+
}>,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function text(children: ReactNode): string {
|
|
25
|
+
return Children.toArray(children)
|
|
26
|
+
.map((child) =>
|
|
27
|
+
isValidElement<{ children?: ReactNode }>(child)
|
|
28
|
+
? text(child.props.children)
|
|
29
|
+
: typeof child === "string" || typeof child === "number"
|
|
30
|
+
? String(child)
|
|
31
|
+
: "",
|
|
32
|
+
)
|
|
33
|
+
.join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type TableElement = ReturnType<typeof elements>[number];
|
|
37
|
+
|
|
38
|
+
function isElementOnly(children: ReactNode): boolean {
|
|
39
|
+
return Children.toArray(children).every(
|
|
40
|
+
(child) => isValidElement(child) || (typeof child === "string" && !child.trim()),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Rebuilt descendants only preserve children and the column-wide GFM alignment. */
|
|
45
|
+
function hasUnsupportedProps(element: TableElement, cell = false): boolean {
|
|
46
|
+
return Object.keys(element.props).some((key) => {
|
|
47
|
+
if (key === "children" || (cell && key === "align")) return false;
|
|
48
|
+
if (cell && key === "style")
|
|
49
|
+
return Object.keys(element.props.style ?? {}).some((property) => property !== "textAlign");
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rectangularCells(row: TableElement, cellType: "th" | "td"): TableElement[] | undefined {
|
|
55
|
+
if (row.type !== "tr" || hasUnsupportedProps(row) || !isElementOnly(row.props.children))
|
|
56
|
+
return undefined;
|
|
57
|
+
const cells = elements(row.props.children);
|
|
58
|
+
if (
|
|
59
|
+
cells.some(
|
|
60
|
+
(cell) =>
|
|
61
|
+
cell.type !== cellType ||
|
|
62
|
+
hasUnsupportedProps(cell, true) ||
|
|
63
|
+
(cell.props.rowSpan !== undefined && cell.props.rowSpan !== 1) ||
|
|
64
|
+
(cell.props.colSpan !== undefined && cell.props.colSpan !== 1),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return undefined;
|
|
68
|
+
return cells;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Only reconstruct a single rectangular header/body; preserve richer native structures intact. */
|
|
72
|
+
function tableStructure(children: ReactNode) {
|
|
73
|
+
if (!isElementOnly(children)) return undefined;
|
|
74
|
+
const sections = elements(children);
|
|
75
|
+
if (sections.length !== 2 || sections[0].type !== "thead" || sections[1].type !== "tbody")
|
|
76
|
+
return undefined;
|
|
77
|
+
if (
|
|
78
|
+
sections.some(
|
|
79
|
+
(section) => hasUnsupportedProps(section) || !isElementOnly(section.props.children),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return undefined;
|
|
83
|
+
|
|
84
|
+
const headerRows = elements(sections[0].props.children);
|
|
85
|
+
if (headerRows.length !== 1) return undefined;
|
|
86
|
+
const headers = rectangularCells(headerRows[0], "th");
|
|
87
|
+
if (!headers?.length) return undefined;
|
|
88
|
+
|
|
89
|
+
const cells: TableElement[][] = [];
|
|
90
|
+
for (const row of elements(sections[1].props.children)) {
|
|
91
|
+
const rowCells = rectangularCells(row, "td");
|
|
92
|
+
if (!rowCells || rowCells.length !== headers.length) return undefined;
|
|
93
|
+
if (
|
|
94
|
+
rowCells.some(
|
|
95
|
+
(cell, index) =>
|
|
96
|
+
(cell.props.style?.textAlign ?? cell.props.align) !==
|
|
97
|
+
(headers[index].props.style?.textAlign ?? headers[index].props.align),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return undefined;
|
|
101
|
+
cells.push(rowCells);
|
|
102
|
+
}
|
|
103
|
+
return { headers, cells };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function prepareMarkdownTable(children: ReactNode) {
|
|
107
|
+
const structure = tableStructure(children);
|
|
108
|
+
if (!structure) return undefined;
|
|
109
|
+
const { headers, cells } = structure;
|
|
110
|
+
const columns: Column[] = headers.map((cell, index) => {
|
|
111
|
+
const align = cell.props.style?.textAlign ?? cell.props.align;
|
|
112
|
+
const alignment =
|
|
113
|
+
align === "left" || align === "center" || align === "right" ? align : undefined;
|
|
114
|
+
const column = inferColumn(
|
|
115
|
+
`column-${index}`,
|
|
116
|
+
text(cell.props.children) || `Column ${index + 1}`,
|
|
117
|
+
cells.map((row) => text(row[index]?.props.children)),
|
|
118
|
+
);
|
|
119
|
+
// Converting units must never erase a link or an inline component.
|
|
120
|
+
if (
|
|
121
|
+
(column.type === "time" || column.type === "bytes") &&
|
|
122
|
+
cells.some((row) => elements(row[index]?.props.children).length > 0)
|
|
123
|
+
)
|
|
124
|
+
return { key: column.key, label: column.label, type: "text", align: alignment };
|
|
125
|
+
return { ...column, align: alignment };
|
|
126
|
+
});
|
|
127
|
+
const data: Row[] = cells.map((row) =>
|
|
128
|
+
Object.fromEntries(
|
|
129
|
+
columns.map((column, index) => {
|
|
130
|
+
const value = text(row[index]?.props.children).trim();
|
|
131
|
+
return [
|
|
132
|
+
column.key,
|
|
133
|
+
column.type === "text"
|
|
134
|
+
? value
|
|
135
|
+
: value === "" || value === "—"
|
|
136
|
+
? null
|
|
137
|
+
: (parseBound(value, column) ?? null),
|
|
138
|
+
];
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return { columns, data, cells, headers };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderMarkdownCell(
|
|
147
|
+
table: NonNullable<ReturnType<typeof prepareMarkdownTable>>,
|
|
148
|
+
row: number,
|
|
149
|
+
key: string,
|
|
150
|
+
) {
|
|
151
|
+
const index = Number(key.slice(7));
|
|
152
|
+
const column = table.columns[index];
|
|
153
|
+
|
|
154
|
+
// Unit columns use DataTable's shared-unit formatter; other cells retain their original markup.
|
|
155
|
+
if (column.type === "time" || column.type === "bytes") return undefined;
|
|
156
|
+
return table.cells[row][index]?.props.children;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function MarkdownTable({
|
|
160
|
+
children,
|
|
161
|
+
id,
|
|
162
|
+
"data-table-persist": persist,
|
|
163
|
+
...tableProps
|
|
164
|
+
}: TableHTMLAttributes<HTMLTableElement> & { "data-table-persist"?: string }) {
|
|
165
|
+
const fallbackId = useId();
|
|
166
|
+
const table = prepareMarkdownTable(children);
|
|
167
|
+
|
|
168
|
+
if (!table)
|
|
169
|
+
return (
|
|
170
|
+
<div className="data-table">
|
|
171
|
+
<div className="data-table-scroll">
|
|
172
|
+
<table {...tableProps} id={id}>
|
|
173
|
+
{children}
|
|
174
|
+
</table>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<DataTable
|
|
181
|
+
id={id ?? `markdown-${fallbackId}`}
|
|
182
|
+
columns={table.columns}
|
|
183
|
+
data={table.data}
|
|
184
|
+
persistState={Boolean(id) && persist !== "false"}
|
|
185
|
+
tableProps={{ ...tableProps, id }}
|
|
186
|
+
renderHeader={(key) => table.headers[Number(key.slice(7))]?.props.children}
|
|
187
|
+
renderCell={(row, key) => renderMarkdownCell(table, row, key)}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
package/client/MdSection.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AnimatePresence } from "framer-motion";
|
|
2
|
+
import { DiagramEditContext, type ExistingDiagram } from "./diagrams/edit-context";
|
|
1
3
|
import {
|
|
2
4
|
lazy,
|
|
3
5
|
startTransition,
|
|
@@ -12,6 +14,7 @@ import { useAtom } from "jotai";
|
|
|
12
14
|
import { trpcClient } from "./api";
|
|
13
15
|
import { DocContext } from "./DocContext";
|
|
14
16
|
import { openSectionAtom } from "./state";
|
|
17
|
+
import { shellInfo } from "./router";
|
|
15
18
|
import { IconButton } from "./ui/IconButton";
|
|
16
19
|
import { pushToast } from "./ui/Toast";
|
|
17
20
|
|
|
@@ -23,6 +26,9 @@ import { pushToast } from "./ui/Toast";
|
|
|
23
26
|
// when React commits the "edit" branch below, which SSR never reaches.
|
|
24
27
|
const loadEditor = () => import("./MdSectionEditor");
|
|
25
28
|
const MdSectionEditor = lazy(loadEditor);
|
|
29
|
+
const DiagramEditDialog = lazy(() =>
|
|
30
|
+
import("./diagrams/EditDialog").then((module) => ({ default: module.DiagramEditDialog })),
|
|
31
|
+
);
|
|
26
32
|
|
|
27
33
|
// Warm the editor chunk as soon as a pointer lands on any section, so by the
|
|
28
34
|
// time the user has double-clicked or reached the pencil the lazy import is
|
|
@@ -94,7 +100,8 @@ function Section({
|
|
|
94
100
|
endLine: number;
|
|
95
101
|
children: ReactNode;
|
|
96
102
|
}) {
|
|
97
|
-
const [mode, setMode] = useState<"read" | "loading" | "edit">("read");
|
|
103
|
+
const [mode, setMode] = useState<"read" | "loading" | "edit" | "diagram">("read");
|
|
104
|
+
const [initialDiagram, setInitialDiagram] = useState<ExistingDiagram | undefined>();
|
|
98
105
|
const [source, setSource] = useState("");
|
|
99
106
|
const [version, setVersion] = useState("");
|
|
100
107
|
const [openSection, setOpenSection] = useAtom(openSectionAtom);
|
|
@@ -120,9 +127,11 @@ function Section({
|
|
|
120
127
|
};
|
|
121
128
|
}, [key, setOpenSection]);
|
|
122
129
|
|
|
123
|
-
async function startEdit() {
|
|
130
|
+
async function startEdit(target?: ExistingDiagram) {
|
|
131
|
+
if (shellInfo.permissions === "restricted") return;
|
|
124
132
|
// Double-click and the pencil can both fire while a fetch is in flight.
|
|
125
133
|
if (mode !== "read") return;
|
|
134
|
+
setInitialDiagram(target);
|
|
126
135
|
setOpenSection(key);
|
|
127
136
|
setMode("loading");
|
|
128
137
|
try {
|
|
@@ -145,7 +154,7 @@ function Section({
|
|
|
145
154
|
// one go — instead of swapping in the Suspense fallback first and
|
|
146
155
|
// then the editor, which read as a flash.
|
|
147
156
|
startTransition(() => {
|
|
148
|
-
setMode((current) => (current === "loading" ? "edit" : current));
|
|
157
|
+
setMode((current) => (current === "loading" ? (target ? "diagram" : "edit") : current));
|
|
149
158
|
});
|
|
150
159
|
} catch {
|
|
151
160
|
pushToast({ tone: "danger", text: "Couldn't open section for editing" });
|
|
@@ -202,6 +211,7 @@ function Section({
|
|
|
202
211
|
// word inside a code block, and svg-pan-zoom's double-click zoom on a
|
|
203
212
|
// mermaid diagram (client/Mermaid.tsx `dblClickZoomEnabled`).
|
|
204
213
|
function onDoubleClick(event: MouseEvent<HTMLDivElement>) {
|
|
214
|
+
if (shellInfo.permissions === "restricted") return;
|
|
205
215
|
const target = event.target as Element | null;
|
|
206
216
|
if (target?.closest("a, button, input, select, textarea, summary, pre, svg")) return;
|
|
207
217
|
event.preventDefault();
|
|
@@ -209,26 +219,51 @@ function Section({
|
|
|
209
219
|
}
|
|
210
220
|
|
|
211
221
|
return (
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
<DiagramEditContext.Provider
|
|
223
|
+
value={
|
|
224
|
+
shellInfo.permissions !== "restricted" && mode === "read"
|
|
225
|
+
? (target) => void startEdit(target)
|
|
226
|
+
: null
|
|
227
|
+
}
|
|
217
228
|
>
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
<AnimatePresence>
|
|
230
|
+
{mode === "diagram" && initialDiagram && (
|
|
231
|
+
<Suspense fallback={null}>
|
|
232
|
+
<DiagramEditDialog
|
|
233
|
+
source={source}
|
|
234
|
+
target={initialDiagram}
|
|
235
|
+
path={path}
|
|
236
|
+
version={version}
|
|
237
|
+
startLine={startLine}
|
|
238
|
+
endLine={endLine}
|
|
239
|
+
onClose={finishEdit}
|
|
240
|
+
/>
|
|
241
|
+
</Suspense>
|
|
242
|
+
)}
|
|
243
|
+
</AnimatePresence>
|
|
244
|
+
<div
|
|
245
|
+
className="mdx-section group"
|
|
246
|
+
data-md-section={index}
|
|
247
|
+
onDoubleClick={shellInfo.permissions === "restricted" ? undefined : onDoubleClick}
|
|
248
|
+
onPointerEnter={shellInfo.permissions === "restricted" ? undefined : preloadEditor}
|
|
249
|
+
>
|
|
250
|
+
{children}
|
|
251
|
+
{/* Floats over the section's top-right corner rather than in the left
|
|
220
252
|
gutter, which is where DocView's left ResizeHandle lives — the two
|
|
221
253
|
hover affordances were fighting for the same strip of pixels. */}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
254
|
+
{shellInfo.permissions !== "restricted" && (
|
|
255
|
+
<IconButton
|
|
256
|
+
icon="pencil"
|
|
257
|
+
label="Edit section"
|
|
258
|
+
size="sm"
|
|
259
|
+
variant="outline"
|
|
260
|
+
data-print-hide
|
|
261
|
+
disabled={mode === "loading"}
|
|
262
|
+
onClick={() => void startEdit()}
|
|
263
|
+
className="mdx-section-edit absolute -top-3 right-0 z-20 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</DiagramEditContext.Provider>
|
|
233
268
|
);
|
|
234
269
|
}
|