@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.
- 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 +115 -5
- package/skills/mdxserve/SKILL.md +178 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useAtom } from "jotai";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { ResizeHandle } from "../ui/ResizeHandle";
|
|
4
|
+
import { TocList } from "../ui/TocList";
|
|
5
|
+
import { TOC_MAX_WIDTH, TOC_MIN_WIDTH, tocWidthAtom } from "../state";
|
|
6
|
+
import { useToc } from "./useToc";
|
|
7
|
+
|
|
8
|
+
function clampWidth(px: number): number {
|
|
9
|
+
return Math.min(TOC_MAX_WIDTH, Math.max(TOC_MIN_WIDTH, Math.round(px)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** "On this page" rail. Hidden entirely when the doc has no h2s. */
|
|
13
|
+
export function TocRail({ path, version }: { path: string; version: number }) {
|
|
14
|
+
const { items, activeId, setActiveId } = useToc(path, version);
|
|
15
|
+
const [width, setWidth] = useAtom(tocWidthAtom);
|
|
16
|
+
const railRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const hasH2 = items.some((item) => item.level === 2);
|
|
18
|
+
if (!hasH2) return null;
|
|
19
|
+
|
|
20
|
+
function handleSelect(id: string) {
|
|
21
|
+
setActiveId(id);
|
|
22
|
+
history.replaceState(null, "", `#${id}`);
|
|
23
|
+
document.getElementById(id)?.scrollIntoView({ block: "start" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
ref={railRef}
|
|
29
|
+
data-print-hide
|
|
30
|
+
className="hidden xl:block shrink-0 self-start ml-10 sticky top-[calc(var(--topbar-height)+40px)]"
|
|
31
|
+
style={{ width: clampWidth(width) }}
|
|
32
|
+
>
|
|
33
|
+
{/* The rail is flush against the main column's right edge, so the
|
|
34
|
+
handle sits on its left and the drag maps 1:1 to the width. It's a
|
|
35
|
+
sibling of the scroller, not inside it, so overflow-y-auto can't clip it. */}
|
|
36
|
+
<ResizeHandle
|
|
37
|
+
side="left"
|
|
38
|
+
anchored
|
|
39
|
+
container={railRef}
|
|
40
|
+
onResize={setWidth}
|
|
41
|
+
label="Resize table of contents"
|
|
42
|
+
minWidth={TOC_MIN_WIDTH}
|
|
43
|
+
maxWidth={TOC_MAX_WIDTH}
|
|
44
|
+
/>
|
|
45
|
+
{/* Capped to the viewport (less the sticky offset and matching bottom
|
|
46
|
+
margin) so a long list scrolls here instead of running below the fold,
|
|
47
|
+
and the handle's resting pill stays centred in what's visible. */}
|
|
48
|
+
<div className="max-h-[calc(100vh-var(--topbar-height)-80px)] overflow-y-auto">
|
|
49
|
+
<TocList items={items} activeId={activeId} onSelect={handleSelect} />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ExportFormat } from "../../src/export/service";
|
|
2
|
+
import Dropdown from "../builtins/Dropdown";
|
|
3
|
+
import { Icon } from "../ui/Icon";
|
|
4
|
+
import { IconButton } from "../ui/IconButton";
|
|
5
|
+
import { Kbd } from "../ui/Kbd";
|
|
6
|
+
import type { Theme } from "../theme";
|
|
7
|
+
import { isApplePlatform } from "../platform";
|
|
8
|
+
|
|
9
|
+
// The export formats the viewer offers; the server's `exportDoc` validates
|
|
10
|
+
// against the same `EXPORT_FORMATS` list (src/export/service.ts).
|
|
11
|
+
const EXPORT_FORMAT_OPTIONS: { value: ExportFormat; label: string; description: string }[] = [
|
|
12
|
+
{
|
|
13
|
+
value: "html",
|
|
14
|
+
label: "HTML",
|
|
15
|
+
description: "One self-contained page; diagrams load from a CDN",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function Wordmark({ href }: { href?: string }) {
|
|
20
|
+
const content = (
|
|
21
|
+
<>
|
|
22
|
+
<span className="font-extrabold text-text-heading">mdx</span>
|
|
23
|
+
<span className="font-normal text-text-accent">serve</span>
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
if (!href) {
|
|
27
|
+
return <span className="flex shrink-0 items-baseline gap-px text-[15px]">{content}</span>;
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<a
|
|
31
|
+
href={href}
|
|
32
|
+
className="flex shrink-0 items-baseline gap-px text-[15px] no-underline [letter-spacing:var(--tracking-tight)]"
|
|
33
|
+
>
|
|
34
|
+
{content}
|
|
35
|
+
</a>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function TopBar({
|
|
40
|
+
homeHref,
|
|
41
|
+
hostLabel,
|
|
42
|
+
sidebar,
|
|
43
|
+
search,
|
|
44
|
+
exportDoc,
|
|
45
|
+
theme,
|
|
46
|
+
onToggleTheme,
|
|
47
|
+
}: {
|
|
48
|
+
/** Wordmark's link target; undefined renders it as a plain (non-link) span. */
|
|
49
|
+
homeHref?: string;
|
|
50
|
+
/** Host chip shown next to the wordmark; omitted when undefined. */
|
|
51
|
+
hostLabel?: string;
|
|
52
|
+
/** Sidebar toggle button; omitted when undefined. */
|
|
53
|
+
sidebar?: { open: boolean; onToggle: () => void };
|
|
54
|
+
/** Search button; omitted when undefined. */
|
|
55
|
+
search?: { onOpen: () => void };
|
|
56
|
+
/** Export menu (doc pages, same-machine viewers only); omitted when undefined. */
|
|
57
|
+
exportDoc?: { pending: boolean; onExport: (format: ExportFormat) => void };
|
|
58
|
+
theme: Theme;
|
|
59
|
+
onToggleTheme: () => void;
|
|
60
|
+
}) {
|
|
61
|
+
const mac = isApplePlatform();
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<header
|
|
65
|
+
data-print-hide
|
|
66
|
+
className="sticky top-0 z-10 flex h-topbar shrink-0 items-center gap-3 border-b border-border-subtle px-4"
|
|
67
|
+
style={{
|
|
68
|
+
background: "color-mix(in oklab, var(--surface-page) 82%, transparent)",
|
|
69
|
+
backdropFilter: "var(--blur-chrome)",
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{sidebar ? (
|
|
73
|
+
<IconButton
|
|
74
|
+
icon="panel-left"
|
|
75
|
+
label="Toggle sidebar"
|
|
76
|
+
size="sm"
|
|
77
|
+
active={sidebar.open}
|
|
78
|
+
onClick={sidebar.onToggle}
|
|
79
|
+
/>
|
|
80
|
+
) : null}
|
|
81
|
+
<Wordmark href={homeHref} />
|
|
82
|
+
{hostLabel ? (
|
|
83
|
+
<span className="rounded-sm border border-border-default px-1.5 py-0.5 font-mono text-[length:var(--size-2xs)] text-text-subtle">
|
|
84
|
+
{hostLabel}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
<span className="flex-1" />
|
|
88
|
+
{search ? (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={search.onOpen}
|
|
92
|
+
className="flex h-[30px] w-60 items-center gap-2 rounded-md border border-border-default bg-surface-card px-2.5 text-[13px] text-text-subtle shadow-xs cursor-pointer"
|
|
93
|
+
>
|
|
94
|
+
<Icon name="search" size="sm" />
|
|
95
|
+
<span className="flex-1 text-left">Search docs</span>
|
|
96
|
+
<Kbd>{mac ? "⌘K" : "Ctrl K"}</Kbd>
|
|
97
|
+
</button>
|
|
98
|
+
) : null}
|
|
99
|
+
{exportDoc ? (
|
|
100
|
+
<Dropdown
|
|
101
|
+
icon="download"
|
|
102
|
+
label="Export page"
|
|
103
|
+
size="sm"
|
|
104
|
+
disabled={exportDoc.pending}
|
|
105
|
+
options={EXPORT_FORMAT_OPTIONS}
|
|
106
|
+
onChange={(format) => exportDoc.onExport(format as ExportFormat)}
|
|
107
|
+
/>
|
|
108
|
+
) : null}
|
|
109
|
+
<IconButton
|
|
110
|
+
icon={theme === "dark" ? "sun" : "moon"}
|
|
111
|
+
label={theme === "dark" ? "Light mode" : "Dark mode"}
|
|
112
|
+
size="sm"
|
|
113
|
+
onClick={onToggleTheme}
|
|
114
|
+
/>
|
|
115
|
+
</header>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { useSpring } from "framer-motion";
|
|
3
|
+
import { TRANSITIONS } from "../motion";
|
|
4
|
+
import { DOC_MAX_WIDTH, DOC_MIN_WIDTH } from "../state";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Width of the content area (the flex column holding the route wrapper),
|
|
8
|
+
* tracked with a ResizeObserver so widths follow window/sidebar/toc resizes.
|
|
9
|
+
* A layout effect so the first paint already has a real measurement.
|
|
10
|
+
*/
|
|
11
|
+
export function useContentWidth(ref: React.RefObject<HTMLElement | null>) {
|
|
12
|
+
const [width, setWidth] = useState(0);
|
|
13
|
+
|
|
14
|
+
useLayoutEffect(() => {
|
|
15
|
+
const el = ref.current;
|
|
16
|
+
if (!el) return;
|
|
17
|
+
const measure = () => setWidth(el.clientWidth);
|
|
18
|
+
measure();
|
|
19
|
+
const observer = new ResizeObserver(measure);
|
|
20
|
+
observer.observe(el);
|
|
21
|
+
return () => observer.disconnect();
|
|
22
|
+
}, [ref]);
|
|
23
|
+
|
|
24
|
+
return width;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spring-animated max-width for doc pages, or null while the user hasn't
|
|
29
|
+
* dragged one — the wrapper keeps its default `max-w-prose` then. The spring
|
|
30
|
+
* jumps (not animates) when a drag first activates it, so the page doesn't
|
|
31
|
+
* lurch from a stale value to the grabbed width.
|
|
32
|
+
*/
|
|
33
|
+
export function useDocMaxWidth(contentWidth: number, stored: number | null) {
|
|
34
|
+
// glide, not snap: the width trails the drag slightly so the spring is felt.
|
|
35
|
+
const spring = useSpring(0, TRANSITIONS.glide);
|
|
36
|
+
const active = useRef(false);
|
|
37
|
+
// Whether the applied target used a real content-area measurement. The first
|
|
38
|
+
// effect run can see contentWidth === 0 (measurement lands one render later);
|
|
39
|
+
// keep jumping until a measured target is applied so a stored width doesn't
|
|
40
|
+
// animate down to its clamp on load.
|
|
41
|
+
const measured = useRef(false);
|
|
42
|
+
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
if (stored === null) {
|
|
45
|
+
active.current = false;
|
|
46
|
+
measured.current = false;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const target = Math.min(
|
|
50
|
+
DOC_MAX_WIDTH,
|
|
51
|
+
contentWidth > 0 ? contentWidth : DOC_MAX_WIDTH,
|
|
52
|
+
Math.max(DOC_MIN_WIDTH, stored),
|
|
53
|
+
);
|
|
54
|
+
if (active.current && measured.current) spring.set(target);
|
|
55
|
+
else spring.jump(target);
|
|
56
|
+
active.current = true;
|
|
57
|
+
measured.current = contentWidth > 0;
|
|
58
|
+
}, [contentWidth, stored, spring]);
|
|
59
|
+
|
|
60
|
+
return stored === null ? null : spring;
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TocEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
level: 2 | 3;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Strips the trailing `#` from the Heading.tsx anchor link (see client/Heading.tsx). */
|
|
10
|
+
function stripAnchorGlyph(text: string): string {
|
|
11
|
+
return text.replace(/#\s*$/, "").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collectHeadings(): { entries: TocEntry[]; elements: HTMLElement[] } {
|
|
15
|
+
const article = document.querySelector("article.mdx-prose");
|
|
16
|
+
if (!article) return { entries: [], elements: [] };
|
|
17
|
+
|
|
18
|
+
const elements = Array.from(article.querySelectorAll<HTMLElement>("h2, h3")).filter(
|
|
19
|
+
(el) => el.id,
|
|
20
|
+
);
|
|
21
|
+
const entries: TocEntry[] = elements.map((el) => ({
|
|
22
|
+
id: el.id,
|
|
23
|
+
label: stripAnchorGlyph(el.textContent ?? ""),
|
|
24
|
+
level: el.tagName === "H3" ? 3 : 2,
|
|
25
|
+
}));
|
|
26
|
+
return { entries, elements };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* "On this page" data for the current doc. Re-scans `article.mdx-prose` for
|
|
31
|
+
* h2/h3 elements whenever `path` or `version` changes (`version` is bumped by
|
|
32
|
+
* DocView.onRendered — see client/shell/TocRail.tsx — because the article for
|
|
33
|
+
* a given `path` can render asynchronously after this effect first runs, e.g.
|
|
34
|
+
* the initial server-rendered route's module is still importing on mount).
|
|
35
|
+
*/
|
|
36
|
+
export function useToc(path: string, version: number) {
|
|
37
|
+
const [items, setItems] = useState<TocEntry[]>([]);
|
|
38
|
+
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
|
39
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
40
|
+
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
function collect() {
|
|
43
|
+
const { entries, elements } = collectHeadings();
|
|
44
|
+
setItems(entries);
|
|
45
|
+
|
|
46
|
+
observerRef.current?.disconnect();
|
|
47
|
+
observerRef.current = null;
|
|
48
|
+
if (elements.length === 0) return;
|
|
49
|
+
|
|
50
|
+
const observer = new IntersectionObserver(
|
|
51
|
+
(observed) => {
|
|
52
|
+
for (const entry of observed) {
|
|
53
|
+
if (entry.isIntersecting) {
|
|
54
|
+
setActiveId(entry.target.id);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{ root: null, rootMargin: "-60px 0px -70% 0px" },
|
|
60
|
+
);
|
|
61
|
+
for (const el of elements) observer.observe(el);
|
|
62
|
+
observerRef.current = observer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
collect();
|
|
66
|
+
|
|
67
|
+
const hot = import.meta.hot;
|
|
68
|
+
hot?.on("vite:afterUpdate", collect);
|
|
69
|
+
return () => {
|
|
70
|
+
observerRef.current?.disconnect();
|
|
71
|
+
observerRef.current = null;
|
|
72
|
+
hot?.off?.("vite:afterUpdate", collect);
|
|
73
|
+
};
|
|
74
|
+
}, [path, version]);
|
|
75
|
+
|
|
76
|
+
return { items, activeId, setActiveId };
|
|
77
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { MDXProvider } from "@mdx-js/react";
|
|
4
|
+
import { mdxComponents } from "./mdx-components";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side render of a compiled MDX document's default export, using the
|
|
8
|
+
* exact same `MDXProvider` component map as the browser (`client/entry.tsx`).
|
|
9
|
+
* Used by `src/rendering/render.ts` to catch render-time errors (e.g. a bare identifier
|
|
10
|
+
* from a stray MDX expression) that static analysis can't see.
|
|
11
|
+
*
|
|
12
|
+
* No `MotionConfig` here: it only affects animation defaults, which don't
|
|
13
|
+
* matter for a render that's discarded immediately, and dropping it keeps
|
|
14
|
+
* this entry's dependency surface smaller for SSR.
|
|
15
|
+
*/
|
|
16
|
+
export function renderDoc(Content: ComponentType): string {
|
|
17
|
+
return renderToString(
|
|
18
|
+
<MDXProvider components={mdxComponents}>
|
|
19
|
+
<Content />
|
|
20
|
+
</MDXProvider>,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { MDXProvider } from "@mdx-js/react";
|
|
3
|
+
import { MotionConfig } from "framer-motion";
|
|
4
|
+
import type { ComponentType } from "react";
|
|
5
|
+
import { mdxComponentsBase } from "./mdx-components-base";
|
|
6
|
+
import { docModuleCache } from "./doc-module-cache";
|
|
7
|
+
import { StandaloneShell } from "./shell/StandaloneShell";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What `mdxserve export` embeds as `#__mdxserve_route` JSON, read back by
|
|
11
|
+
* readMeta() below. `mtime` is omitted when the source file's mtime is
|
|
12
|
+
* unavailable at build time.
|
|
13
|
+
*/
|
|
14
|
+
export interface StandaloneMeta {
|
|
15
|
+
/** An opaque key (the file name, never an absolute path — the file gets shared) for docModuleCache/TOC/DocContext. */
|
|
16
|
+
path: string;
|
|
17
|
+
label: string;
|
|
18
|
+
mtime?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parses the `#__mdxserve_route` script tag the same way entry.tsx does. */
|
|
22
|
+
export function readMeta(): StandaloneMeta {
|
|
23
|
+
const el = document.getElementById("__mdxserve_route");
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(el?.textContent ?? "{}") as StandaloneMeta;
|
|
26
|
+
} catch {
|
|
27
|
+
return { path: "", label: document.title };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Entry point for a `mdxserve export` output. The bundler's virtual entry
|
|
33
|
+
* plugin (src/rendering/bundle.ts) generates a module that imports the
|
|
34
|
+
* compiled doc component and calls `mount(Doc, readMeta())` — keep both
|
|
35
|
+
* export names and this signature stable, since that generated import is not
|
|
36
|
+
* type-checked against this file.
|
|
37
|
+
*/
|
|
38
|
+
export function mount(Doc: ComponentType, meta: StandaloneMeta): void {
|
|
39
|
+
const rootEl = document.getElementById("root");
|
|
40
|
+
if (!rootEl) return;
|
|
41
|
+
// DocView reads the module from the router's cache; seed it with the doc
|
|
42
|
+
// that was compiled into this file so it renders synchronously.
|
|
43
|
+
docModuleCache.set(meta.path, { status: "ok", Component: Doc });
|
|
44
|
+
createRoot(rootEl).render(
|
|
45
|
+
<MotionConfig reducedMotion="user">
|
|
46
|
+
<MDXProvider components={mdxComponentsBase}>
|
|
47
|
+
<StandaloneShell meta={meta} />
|
|
48
|
+
</MDXProvider>
|
|
49
|
+
</MotionConfig>,
|
|
50
|
+
);
|
|
51
|
+
}
|
package/client/state.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { atom } from "jotai";
|
|
2
|
+
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
|
3
|
+
|
|
4
|
+
export type SortKey = "name" | "modified";
|
|
5
|
+
|
|
6
|
+
function isSortKey(value: unknown): value is SortKey {
|
|
7
|
+
return value === "name" || value === "modified";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The pre-jotai code stored the sort as a bare string (`modified`), which
|
|
11
|
+
// jotai's JSON storage can't parse. Accept both forms so an existing choice
|
|
12
|
+
// survives the upgrade; writes go through the JSON storage as usual.
|
|
13
|
+
const sortStorage = createJSONStorage<SortKey>(() => localStorage);
|
|
14
|
+
const legacyAwareSortStorage = {
|
|
15
|
+
...sortStorage,
|
|
16
|
+
getItem(key: string, initialValue: SortKey): SortKey {
|
|
17
|
+
try {
|
|
18
|
+
const raw = localStorage.getItem(key);
|
|
19
|
+
if (raw === null) return initialValue;
|
|
20
|
+
try {
|
|
21
|
+
const parsed: unknown = JSON.parse(raw);
|
|
22
|
+
return isSortKey(parsed) ? parsed : initialValue;
|
|
23
|
+
} catch {
|
|
24
|
+
return isSortKey(raw) ? raw : initialValue;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return initialValue;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Folder listing sort order. */
|
|
33
|
+
export const listingSortAtom = atomWithStorage<SortKey>(
|
|
34
|
+
"mdxserve.listing.sort",
|
|
35
|
+
"name",
|
|
36
|
+
legacyAwareSortStorage,
|
|
37
|
+
{ getOnInit: true },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const SIDEBAR_MIN_WIDTH = 200;
|
|
41
|
+
export const SIDEBAR_MAX_WIDTH = 560;
|
|
42
|
+
export const SIDEBAR_DEFAULT_WIDTH = 264;
|
|
43
|
+
|
|
44
|
+
/** Docked sidebar width in px; dragged via the resize handle. */
|
|
45
|
+
export const sidebarWidthAtom = atomWithStorage<number>(
|
|
46
|
+
"mdxserve.sidebar.width",
|
|
47
|
+
SIDEBAR_DEFAULT_WIDTH,
|
|
48
|
+
undefined,
|
|
49
|
+
{ getOnInit: true },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const TOC_MIN_WIDTH = 160;
|
|
53
|
+
export const TOC_MAX_WIDTH = 420;
|
|
54
|
+
export const TOC_DEFAULT_WIDTH = 220;
|
|
55
|
+
|
|
56
|
+
/** "On this page" rail width in px; dragged via the resize handle. */
|
|
57
|
+
export const tocWidthAtom = atomWithStorage<number>(
|
|
58
|
+
"mdxserve.toc.width",
|
|
59
|
+
TOC_DEFAULT_WIDTH,
|
|
60
|
+
undefined,
|
|
61
|
+
{ getOnInit: true },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const LISTING_MIN_WIDTH = 360;
|
|
65
|
+
export const LISTING_MAX_WIDTH = 1600;
|
|
66
|
+
|
|
67
|
+
/** Folder listing width in px, or null for the default prose width. */
|
|
68
|
+
export const listingWidthAtom = atomWithStorage<number | null>(
|
|
69
|
+
"mdxserve.listing.width",
|
|
70
|
+
null,
|
|
71
|
+
undefined,
|
|
72
|
+
{ getOnInit: true },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const DOC_MIN_WIDTH = 480;
|
|
76
|
+
export const DOC_MAX_WIDTH = 1600;
|
|
77
|
+
|
|
78
|
+
/** Doc page width in px, or null for the default prose width. */
|
|
79
|
+
export const docWidthAtom = atomWithStorage<number | null>("mdxserve.doc.width", null, undefined, {
|
|
80
|
+
getOnInit: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The key (`${path}:${startLine}:${endLine}`) of the `MdSection` currently in
|
|
85
|
+
* edit mode, or null. Plain in-memory atom — not persisted, since an open
|
|
86
|
+
* editor holding unsaved edits should never survive a reload. Only one
|
|
87
|
+
* section edits at a time: a `Section` that isn't this atom's value drops
|
|
88
|
+
* back to read mode, discarding whatever it had open (see client/MdSection.tsx).
|
|
89
|
+
*/
|
|
90
|
+
export const openSectionAtom = atom<string | null>(null);
|
package/client/theme.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type Theme = "light" | "dark";
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "mdxserve-theme";
|
|
6
|
+
const MEDIA = "(prefers-color-scheme: dark)";
|
|
7
|
+
|
|
8
|
+
function systemTheme(): Theme {
|
|
9
|
+
return window.matchMedia(MEDIA).matches ? "dark" : "light";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function storedTheme(): Theme | null {
|
|
13
|
+
try {
|
|
14
|
+
const value = localStorage.getItem(STORAGE_KEY);
|
|
15
|
+
return value === "light" || value === "dark" ? value : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyTheme(theme: Theme): void {
|
|
22
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The page theme. `src/http/html.ts` sets `data-theme` on `<html>` before the
|
|
27
|
+
* stylesheet loads (same rules as here) so there is no flash; this hook keeps
|
|
28
|
+
* that attribute, localStorage, and React state in sync afterwards.
|
|
29
|
+
*
|
|
30
|
+
* An explicit choice is persisted; "follow the system" is represented by the
|
|
31
|
+
* absence of a stored value, so the OS setting keeps working until the reader
|
|
32
|
+
* picks a side.
|
|
33
|
+
*/
|
|
34
|
+
export function useTheme(): { theme: Theme; toggle: () => void; set: (theme: Theme) => void } {
|
|
35
|
+
const [theme, setTheme] = useState<Theme>(() => storedTheme() ?? systemTheme());
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
applyTheme(theme);
|
|
39
|
+
}, [theme]);
|
|
40
|
+
|
|
41
|
+
// Follow OS changes only while the reader has not chosen explicitly.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const mql = window.matchMedia(MEDIA);
|
|
44
|
+
const onChange = () => {
|
|
45
|
+
if (storedTheme() === null) setTheme(systemTheme());
|
|
46
|
+
};
|
|
47
|
+
mql.addEventListener("change", onChange);
|
|
48
|
+
return () => mql.removeEventListener("change", onChange);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const set = useCallback((next: Theme) => {
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
54
|
+
} catch {
|
|
55
|
+
// private mode / storage disabled: the choice just won't persist
|
|
56
|
+
}
|
|
57
|
+
setTheme(next);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const toggle = useCallback(() => {
|
|
61
|
+
set(theme === "dark" ? "light" : "dark");
|
|
62
|
+
}, [theme, set]);
|
|
63
|
+
|
|
64
|
+
return { theme, toggle, set };
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Fragment, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { Icon } from "./Icon";
|
|
3
|
+
|
|
4
|
+
export interface BreadcrumbItem {
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BreadcrumbProps extends HTMLAttributes<HTMLElement> {
|
|
10
|
+
items?: BreadcrumbItem[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Path trail above the prose column. */
|
|
14
|
+
export function Breadcrumb({ items = [], className, ...rest }: BreadcrumbProps) {
|
|
15
|
+
return (
|
|
16
|
+
<nav
|
|
17
|
+
className={
|
|
18
|
+
"flex flex-wrap items-center gap-1.5 text-[13px] leading-normal font-medium text-text-subtle" +
|
|
19
|
+
(className ? " " + className : "")
|
|
20
|
+
}
|
|
21
|
+
{...rest}
|
|
22
|
+
>
|
|
23
|
+
{items.map((item, index) => {
|
|
24
|
+
const last = index === items.length - 1;
|
|
25
|
+
const label =
|
|
26
|
+
last || !item.href ? (
|
|
27
|
+
<span
|
|
28
|
+
className={last ? "font-semibold text-text-body" : "font-medium text-text-subtle"}
|
|
29
|
+
>
|
|
30
|
+
{item.label}
|
|
31
|
+
</span>
|
|
32
|
+
) : (
|
|
33
|
+
<a
|
|
34
|
+
href={item.href}
|
|
35
|
+
className="font-medium text-text-subtle no-underline hover:text-text-heading"
|
|
36
|
+
>
|
|
37
|
+
{item.label}
|
|
38
|
+
</a>
|
|
39
|
+
);
|
|
40
|
+
return (
|
|
41
|
+
<Fragment key={index}>
|
|
42
|
+
{index > 0 ? <Icon name="chevron-right" size={12} className="text-n-300" /> : null}
|
|
43
|
+
{label}
|
|
44
|
+
</Fragment>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
}
|