@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.
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 +113 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,72 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { enterTransition, fadeSwapTransition } from "./motion";
4
+
5
+ export interface CrossFadePane {
6
+ key: string;
7
+ node: ReactNode;
8
+ /** Extra props for the pane wrapper (e.g. role="tabpanel", ids). */
9
+ props?: Record<string, unknown>;
10
+ }
11
+
12
+ /**
13
+ * Cross-fades between panes that all stay mounted. Panes are stacked in a
14
+ * single grid cell and the container's height tweens to the active pane's
15
+ * height, so switching never collapses or jumps the layout. Inactive panes
16
+ * are inert and hidden from assistive tech.
17
+ */
18
+ export function CrossFade({
19
+ active,
20
+ panes,
21
+ className,
22
+ }: {
23
+ active: string;
24
+ panes: CrossFadePane[];
25
+ className?: string;
26
+ }) {
27
+ const refs = useRef(new Map<string, HTMLDivElement>());
28
+ const [height, setHeight] = useState<number | null>(null);
29
+
30
+ useLayoutEffect(() => {
31
+ const el = refs.current.get(active);
32
+ if (!el) return;
33
+ const measure = () => setHeight(el.offsetHeight);
34
+ measure();
35
+ const observer = new ResizeObserver(measure);
36
+ observer.observe(el);
37
+ return () => observer.disconnect();
38
+ }, [active, panes.length]);
39
+
40
+ return (
41
+ <motion.div
42
+ className={`grid grid-cols-[minmax(0,1fr)] overflow-hidden ${className ?? ""}`}
43
+ initial={false}
44
+ animate={height === null ? undefined : { height }}
45
+ transition={enterTransition}
46
+ style={height === null ? undefined : { height }}
47
+ >
48
+ {panes.map((pane) => {
49
+ const isActive = pane.key === active;
50
+ return (
51
+ <motion.div
52
+ key={pane.key}
53
+ ref={(el) => {
54
+ if (el) refs.current.set(pane.key, el);
55
+ else refs.current.delete(pane.key);
56
+ }}
57
+ className="min-w-0 [grid-area:1/1] self-start"
58
+ initial={false}
59
+ animate={{ opacity: isActive ? 1 : 0 }}
60
+ transition={fadeSwapTransition}
61
+ style={{ pointerEvents: isActive ? "auto" : "none" }}
62
+ inert={!isActive}
63
+ aria-hidden={!isActive}
64
+ {...pane.props}
65
+ >
66
+ {pane.node}
67
+ </motion.div>
68
+ );
69
+ })}
70
+ </motion.div>
71
+ );
72
+ }
@@ -0,0 +1,14 @@
1
+ import { createContext } from "react";
2
+
3
+ /**
4
+ * The absolute on-disk path of the doc currently rendering. Provided by
5
+ * `DocView` around the compiled MDX module's `<Content/>`, so any
6
+ * `MdSection` inside it (including nested ones re-imported via HMR) can
7
+ * reach the path without threading it through every builtin's props.
8
+ *
9
+ * `null` outside a doc render — e.g. `MdSection` also has to work when the
10
+ * SSR worker (`client/ssr-entry.tsx`) renders a doc module directly for
11
+ * `validate_doc`, which has no router and thus no provider above it. See the
12
+ * `location`-based fallback in `client/MdSection.tsx`.
13
+ */
14
+ export const DocContext = createContext<{ path: string } | null>(null);
@@ -0,0 +1,107 @@
1
+ import { useLayoutEffect, useMemo, useRef } from "react";
2
+ import { useAtomValue, useSetAtom } from "jotai";
3
+ import { DocContext } from "./DocContext";
4
+ import { ErrorBox } from "./ErrorBox";
5
+ import { RenderErrorBoundary } from "./RenderErrorBoundary";
6
+ import type { DocModuleState } from "./doc-module-cache";
7
+ import { ResizeHandle } from "./ui/ResizeHandle";
8
+ import { DOC_MAX_WIDTH, DOC_MIN_WIDTH, docWidthAtom, openSectionAtom } from "./state";
9
+
10
+ // Each Component a re-import produces (including an HMR re-import after a
11
+ // fix) is a distinct function identity, so this assigns it a stable, unique
12
+ // key the RenderErrorBoundary below can be keyed on to reset itself.
13
+ const moduleKeys = new WeakMap<object, number>();
14
+ let nextModuleKey = 0;
15
+ function keyForComponent(Component: object): number {
16
+ let key = moduleKeys.get(Component);
17
+ if (key === undefined) {
18
+ key = nextModuleKey++;
19
+ moduleKeys.set(Component, key);
20
+ }
21
+ return key;
22
+ }
23
+
24
+ export function DocView({
25
+ path,
26
+ module,
27
+ onRendered,
28
+ }: {
29
+ path: string;
30
+ /**
31
+ * The cached module for `path` (client/doc-module-cache.ts), read
32
+ * by the caller rather than here: keeping this component's own import
33
+ * graph free of router.ts (and the tRPC/react-query it pulls in) is what
34
+ * lets client/standalone-entry.tsx reuse it without either.
35
+ */
36
+ module: DocModuleState | undefined;
37
+ /**
38
+ * Fired once the article for the *current* cached module is on the page —
39
+ * keyed on the cached entry's identity (not just route.path) so it fires
40
+ * exactly once per resolved module, including the async case where the
41
+ * initial server-rendered route's module is still importing on mount (see
42
+ * the bootstrap effect in client/router.ts). Lets TocRail/useToc know when
43
+ * it's safe to re-scan the DOM for headings.
44
+ */
45
+ onRendered?: () => void;
46
+ }) {
47
+ const cached = module;
48
+ const setWidth = useSetAtom(docWidthAtom);
49
+ // While a section is being edited the page width is pinned: a drag would
50
+ // reflow the editor under the caret, and the handles' hover strips sit
51
+ // exactly where the frame's glow and hints live.
52
+ const editing = useAtomValue(openSectionAtom) !== null;
53
+ const container = useRef<HTMLDivElement>(null);
54
+ // Every `MdSection` inside `<Content/>` reads the doc's path off this
55
+ // context (see client/DocContext.ts) instead of a prop, since MDX content
56
+ // components render through the MDXProvider map and never see route props
57
+ // directly. Memoized on the path so identity-sensitive children (none
58
+ // currently, but cheap insurance) don't see a new object every render.
59
+ const docContext = useMemo(() => ({ path }), [path]);
60
+
61
+ useLayoutEffect(() => {
62
+ if (cached?.status === "ok") onRendered?.();
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [cached, onRendered]);
65
+
66
+ // The router always resolves the module before setting a "doc" route (see
67
+ // client/router.ts loadRoute / the initial-route bootstrap effect), so this
68
+ // is only ever transiently empty on the very first render after boot.
69
+ if (!cached) return null;
70
+
71
+ if (cached.status === "error") {
72
+ return <ErrorBox message={cached.message} />;
73
+ }
74
+
75
+ const Content = cached.Component;
76
+ return (
77
+ <div ref={container} className="relative">
78
+ {!editing && (
79
+ <>
80
+ <ResizeHandle
81
+ side="left"
82
+ container={container}
83
+ onResize={setWidth}
84
+ label="Resize page"
85
+ minWidth={DOC_MIN_WIDTH}
86
+ maxWidth={DOC_MAX_WIDTH}
87
+ />
88
+ <ResizeHandle
89
+ side="right"
90
+ container={container}
91
+ onResize={setWidth}
92
+ label="Resize page"
93
+ minWidth={DOC_MIN_WIDTH}
94
+ maxWidth={DOC_MAX_WIDTH}
95
+ />
96
+ </>
97
+ )}
98
+ <article className="mdx-prose min-w-0 max-w-full">
99
+ <DocContext value={docContext}>
100
+ <RenderErrorBoundary key={keyForComponent(Content)}>
101
+ <Content />
102
+ </RenderErrorBoundary>
103
+ </DocContext>
104
+ </article>
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,23 @@
1
+ import { motion } from "framer-motion";
2
+ import { fadeRise } from "./motion";
3
+
4
+ /**
5
+ * Shown in place of a doc's content when it fails to render — either a
6
+ * dynamic import error (DocView) or a render-time throw caught by
7
+ * RenderErrorBoundary. Lives in its own file (not DocView.tsx) so
8
+ * RenderErrorBoundary can import it without importing DocView, which would
9
+ * otherwise cycle back through client/router.ts and client/api.ts.
10
+ */
11
+ export function ErrorBox({ message }: { message: string }) {
12
+ return (
13
+ <motion.div
14
+ variants={fadeRise}
15
+ initial="initial"
16
+ animate="enter"
17
+ className="rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
18
+ >
19
+ <p className="mb-2 font-semibold">Failed to render this page</p>
20
+ <pre className="whitespace-pre-wrap break-words font-mono text-xs">{message}</pre>
21
+ </motion.div>
22
+ );
23
+ }
@@ -0,0 +1,31 @@
1
+ import type { ComponentPropsWithoutRef, ElementType } from "react";
2
+
3
+ /**
4
+ * MDXProvider `h2`/`h3`/`h4` overrides. `rehype-slug` (see client/entry.tsx)
5
+ * stamps an `id` on every heading it compiles; this factory adds the hover
6
+ * affordance that links to it (`.mdx-anchor`, styled + positioned by
7
+ * client/design/base/prose.css — it only shows up on heading hover or focus).
8
+ * A heading with no `id` (hand-written JSX without rehype-slug) renders
9
+ * without the anchor rather than linking to nothing.
10
+ */
11
+ function makeHeading<Level extends "h2" | "h3" | "h4">(tag: Level) {
12
+ // JSX can't use a generic type parameter directly as a tag name; narrow it
13
+ // to `ElementType` once here so `<Tag>` below type-checks as an intrinsic element.
14
+ const Tag = tag as ElementType;
15
+ return function Heading({ id, children, ...rest }: ComponentPropsWithoutRef<Level>) {
16
+ return (
17
+ <Tag id={id} {...rest}>
18
+ {children}
19
+ {id ? (
20
+ <a className="mdx-anchor" href={"#" + id} aria-label="Link to this section">
21
+ #
22
+ </a>
23
+ ) : null}
24
+ </Tag>
25
+ );
26
+ };
27
+ }
28
+
29
+ export const H2 = makeHeading("h2");
30
+ export const H3 = makeHeading("h3");
31
+ export const H4 = makeHeading("h4");
@@ -0,0 +1,101 @@
1
+ import { useState } from "react";
2
+ import { AnimatePresence, motion } from "framer-motion";
3
+ import { Icon } from "./ui/Icon";
4
+ import { TRANSITIONS } from "./motion";
5
+
6
+ /** What the Copy control puts on the clipboard: the command up to where the path goes. */
7
+ const COMMAND_PREFIX = "mdxserve roots add ";
8
+
9
+ /**
10
+ * The home page when nothing is mounted. The server is up and this page
11
+ * already re-renders itself on `mdxserve:roots-changed`, so the whole view
12
+ * is framed as a server waiting for a folder rather than as an error: one
13
+ * command to run, a quieter agent alternative, and a live status line that
14
+ * says where the server is listening.
15
+ */
16
+ export function HomeEmptyState() {
17
+ const [copied, setCopied] = useState(false);
18
+ const host = typeof window === "undefined" ? "" : window.location.host;
19
+
20
+ async function copyCommand() {
21
+ try {
22
+ await navigator.clipboard.writeText(COMMAND_PREFIX);
23
+ setCopied(true);
24
+ setTimeout(() => setCopied(false), 1500);
25
+ } catch {
26
+ // clipboard unavailable (insecure context, permissions); the text is still selectable
27
+ }
28
+ }
29
+
30
+ // Layout is all container gaps, no element margins: reset.css zeroes
31
+ // h1/p margins outside any cascade layer, so a `mt-*` utility on them is
32
+ // a no-op while the same class on a div works.
33
+ return (
34
+ <section aria-labelledby="home-empty-heading" className="flex flex-col gap-10 pt-6">
35
+ <div className="flex flex-col gap-2">
36
+ <div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-surface-accent-soft text-text-accent">
37
+ <Icon name="folder-plus" size="lg" />
38
+ </div>
39
+ <h1
40
+ id="home-empty-heading"
41
+ className="font-sans font-bold leading-[1.2] text-[length:var(--size-2xl)] text-text-heading"
42
+ >
43
+ No folders are mounted
44
+ </h1>
45
+ <p className="max-w-[52ch] font-sans leading-relaxed text-[length:var(--size-md)] text-text-muted">
46
+ The server is up and waiting. Mount a folder of Markdown and it appears here on its own,
47
+ no reload needed.
48
+ </p>
49
+ </div>
50
+
51
+ <div className="flex flex-col gap-3">
52
+ <div className="flex items-center gap-3 rounded-md border border-code-border bg-code-bg py-2.5 pr-2 pl-3.5 font-mono text-[length:var(--size-sm)] text-code-fg">
53
+ <span aria-hidden="true" className="select-none text-text-subtle">
54
+ $
55
+ </span>
56
+ <code className="min-w-0 flex-1 truncate">
57
+ {COMMAND_PREFIX}
58
+ <span className="text-text-subtle">&lt;dir&gt;</span>
59
+ </code>
60
+ <motion.button
61
+ type="button"
62
+ onClick={copyCommand}
63
+ whileTap={{ scale: 0.94 }}
64
+ transition={TRANSITIONS.snap}
65
+ className={
66
+ "inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-sm px-2 font-sans text-[length:var(--size-xs)] font-medium leading-none transition-colors " +
67
+ (copied ? "text-text-accent" : "text-text-subtle hover:text-text-heading")
68
+ }
69
+ >
70
+ <AnimatePresence mode="wait" initial={false}>
71
+ <motion.span
72
+ key={copied ? "done" : "idle"}
73
+ initial={{ opacity: 0, y: -3 }}
74
+ animate={{ opacity: 1, y: 0 }}
75
+ exit={{ opacity: 0, y: 3 }}
76
+ transition={TRANSITIONS.fast}
77
+ className="inline-flex items-center gap-1.5"
78
+ >
79
+ <Icon name={copied ? "check" : "copy"} size={13} />
80
+ {copied ? "Copied" : "Copy"}
81
+ </motion.span>
82
+ </AnimatePresence>
83
+ </motion.button>
84
+ </div>
85
+ <p className="font-sans text-[length:var(--size-xs)] leading-relaxed text-text-subtle">
86
+ An agent with the mdxserve skill can run this for you.
87
+ </p>
88
+ </div>
89
+
90
+ <p className="flex items-center gap-2.5 font-mono text-[length:var(--size-xs)] text-text-subtle">
91
+ <span aria-hidden="true" className="relative flex h-2 w-2">
92
+ <span className="absolute inline-flex h-full w-full rounded-pill bg-status-ok-fg opacity-60 motion-safe:animate-ping" />
93
+ <span className="relative inline-flex h-2 w-2 rounded-pill bg-status-ok-fg" />
94
+ </span>
95
+ <span>
96
+ Waiting on <span className="text-text-muted">{host}</span>
97
+ </span>
98
+ </p>
99
+ </section>
100
+ );
101
+ }
@@ -0,0 +1,78 @@
1
+ import { motion } from "framer-motion";
2
+ import { shortenHome } from "./format";
3
+ import { stagger } from "./motion";
4
+ import { Icon } from "./ui/Icon";
5
+ import { HomeEmptyState } from "./HomeEmptyState";
6
+ import { useTree, type Route, type TreeNode } from "./router";
7
+
8
+ function countDocs(nodes: TreeNode[] | null): number {
9
+ if (!nodes) return 0;
10
+ let count = 0;
11
+ for (const node of nodes) {
12
+ if (node.isDoc) count++;
13
+ if (node.children) count += countDocs(node.children);
14
+ }
15
+ return count;
16
+ }
17
+
18
+ const rowVariants = {
19
+ initial: { opacity: 0, y: 4 },
20
+ enter: { opacity: 1, y: 0 },
21
+ };
22
+
23
+ /**
24
+ * Landing page listing every mounted root; the server serves this at "/"
25
+ * whenever the root count isn't exactly one (zero mounted roots, or more
26
+ * than one — a single root redirects straight to its listing instead).
27
+ */
28
+ export function HomeView({ route }: { route: Extract<Route, { kind: "home" }> }) {
29
+ const { roots: liveRoots } = useTree();
30
+ // Roots can be added/removed at runtime; once the tree query has loaded,
31
+ // trust it over the route's own snapshot so the page updates without a
32
+ // reload. Before that first load, fall back to what the server embedded
33
+ // so the list doesn't flash empty.
34
+ const roots = liveRoots ?? route.roots;
35
+ const treeByDir = new Map((liveRoots ?? []).map((root) => [root.dir, root.tree]));
36
+
37
+ if (roots.length === 0) return <HomeEmptyState />;
38
+
39
+ return (
40
+ <motion.div
41
+ variants={stagger}
42
+ initial="initial"
43
+ animate="enter"
44
+ className="divide-y divide-border-subtle border-y border-border-subtle"
45
+ >
46
+ {roots.map((root) => {
47
+ const docCount = countDocs(treeByDir.get(root.dir) ?? null);
48
+ return (
49
+ <motion.div
50
+ key={root.dir}
51
+ variants={rowVariants}
52
+ className="group flex items-center rounded-md hover:bg-surface-hover"
53
+ >
54
+ {/* Plain <a>: the router's global click delegation (client/router.ts)
55
+ intercepts this for client-side navigation. */}
56
+ <a
57
+ href={`${root.dir}/`}
58
+ className="flex min-w-0 flex-1 items-center gap-2 py-2 pr-3 pl-2"
59
+ >
60
+ <Icon name="folder" size="md" className="shrink-0 text-text-subtle" />
61
+ <span className="flex min-w-0 flex-col">
62
+ <span className="truncate font-sans font-medium leading-normal text-[length:var(--size-md)]">
63
+ {root.name}
64
+ </span>
65
+ <span className="truncate font-mono font-normal leading-normal text-[length:var(--size-xs)] text-text-subtle">
66
+ {shortenHome(root.dir)}
67
+ </span>
68
+ </span>
69
+ <span className="ml-auto flex shrink-0 items-center gap-4 pl-4 font-mono font-normal leading-[1.62] text-[length:var(--size-xs)] text-text-subtle tabular-nums">
70
+ {docCount} {docCount === 1 ? "doc" : "docs"}
71
+ </span>
72
+ </a>
73
+ </motion.div>
74
+ );
75
+ })}
76
+ </motion.div>
77
+ );
78
+ }