@karimsa/mdxserve 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -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
+ }
@@ -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);
@@ -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
+ }