@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,401 @@
1
+ import { AnimatePresence, motion, useSpring } from "framer-motion";
2
+ import { useAtomValue } from "jotai";
3
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
+ import { trpc } from "../api";
6
+ import { DocView } from "../DocView";
7
+ import { HomeView } from "../HomeView";
8
+ import { useDebounced } from "../hooks";
9
+ import { ListingView } from "../ListingView";
10
+ import { fadeRise, TRANSITIONS } from "../motion";
11
+ import { useTheme } from "../theme";
12
+ import {
13
+ docModuleCache,
14
+ docMtime,
15
+ shellInfo,
16
+ useFolderListing,
17
+ useTree,
18
+ type Route,
19
+ type TreeNode,
20
+ } from "../router";
21
+ import type { ExportFormat } from "../../src/export/service";
22
+ import { exportDocFromViewer } from "../export-doc";
23
+ import { Breadcrumb, type BreadcrumbItem } from "../ui/Breadcrumb";
24
+ import { PageNav, type PageNavLink } from "../ui/PageNav";
25
+ import { SearchDialog, type SearchResult } from "../ui/SearchDialog";
26
+ import { Footer } from "./Footer";
27
+ import { NotFoundView } from "./NotFoundView";
28
+ import { Sidebar } from "./Sidebar";
29
+ import { TocRail } from "./TocRail";
30
+ import { TopBar } from "./TopBar";
31
+ import { useContentWidth, useDocMaxWidth } from "./use-doc-width";
32
+ import { DESKTOP_MEDIA } from "../platform";
33
+ import { docWidthAtom, LISTING_MAX_WIDTH, LISTING_MIN_WIDTH, listingWidthAtom } from "../state";
34
+
35
+ const SIDEBAR_STORAGE_KEY = "mdxserve-sidebar";
36
+ const SEARCH_DEBOUNCE_MS = 120;
37
+
38
+ function readStoredSidebarOpen(): boolean {
39
+ try {
40
+ const value = localStorage.getItem(SIDEBAR_STORAGE_KEY);
41
+ return value === null ? true : value === "1";
42
+ } catch {
43
+ return true;
44
+ }
45
+ }
46
+
47
+ function stripDocExt(name: string): string {
48
+ return name.replace(/\.mdx?$/i, "");
49
+ }
50
+
51
+ /** Depth-first doc order of the tree, matching the sidebar's rendering order. */
52
+ function flattenDocs(nodes: TreeNode[] | null): TreeNode[] {
53
+ const result: TreeNode[] = [];
54
+ function walk(list: TreeNode[]) {
55
+ for (const node of list) {
56
+ if (node.isDoc) result.push(node);
57
+ if (node.children) walk(node.children);
58
+ }
59
+ }
60
+ if (nodes) walk(nodes);
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Root name -> path segments; every segment is a folder link except a doc's
66
+ * last segment. Folder listings lead with the served directory's absolute
67
+ * path instead, with only the served root onwards being navigable. On the
68
+ * home page there's just the "mdxserve" crumb; everywhere else it's the
69
+ * leading crumb too, but only when the server serves more than one root.
70
+ */
71
+ function breadcrumbItems(route: Route, rootsCount: number): BreadcrumbItem[] {
72
+ if (route.kind === "home") return [{ label: "mdxserve" }];
73
+
74
+ const items: BreadcrumbItem[] = [];
75
+ if (rootsCount > 1) items.push({ label: "mdxserve", href: "/" });
76
+
77
+ const rootDir = route.rootDir ?? "";
78
+ const rootName = route.rootName ?? "";
79
+
80
+ if (route.kind === "listing" && rootDir) {
81
+ // The server hands back its raw root, so split on either separator.
82
+ const parents = rootDir.split(/[\\/]/).filter(Boolean).slice(0, -1);
83
+ if (parents.length === 0) items.push({ label: "/" });
84
+ for (const parent of parents) items.push({ label: parent });
85
+ }
86
+
87
+ if (rootDir) items.push({ label: rootName, href: `${rootDir}/` });
88
+
89
+ const segments = route.path.slice(rootDir.length).split("/").filter(Boolean);
90
+ let acc = rootDir;
91
+ segments.forEach((segment, index) => {
92
+ acc += `/${segment}`;
93
+ const isLast = index === segments.length - 1;
94
+ const isDocLast = isLast && route.kind !== "listing";
95
+ items.push({ label: segment, href: isDocLast ? undefined : `${acc}/` });
96
+ });
97
+ return items;
98
+ }
99
+
100
+ /** Fraction of the content width a folder listing takes by default. */
101
+ const LISTING_DEFAULT_FRACTION = 2 / 3;
102
+
103
+ /**
104
+ * Spring-animated max-width for folder listings: the stored width when the user
105
+ * has dragged one, otherwise 2/3 of the content area's width.
106
+ */
107
+ function useListingMaxWidth(contentWidth: number, stored: number | null) {
108
+ // glide, not snap: the width trails the drag slightly so the spring is felt.
109
+ const spring = useSpring(0, TRANSITIONS.glide);
110
+ const initialised = useRef(false);
111
+
112
+ // A layout effect so the first listing paint already has a real max-width
113
+ // (the wrapper binds the spring immediately and AnimatePresence skips the
114
+ // initial animation).
115
+ useLayoutEffect(() => {
116
+ if (contentWidth <= 0) return;
117
+ const fallback = Math.round(contentWidth * LISTING_DEFAULT_FRACTION);
118
+ const target = Math.min(
119
+ LISTING_MAX_WIDTH,
120
+ contentWidth,
121
+ Math.max(LISTING_MIN_WIDTH, stored ?? fallback),
122
+ );
123
+ // First measurement: land on the value without animating up from 0.
124
+ if (initialised.current) spring.set(target);
125
+ else {
126
+ spring.jump(target);
127
+ initialised.current = true;
128
+ }
129
+ }, [contentWidth, stored, spring]);
130
+
131
+ return spring;
132
+ }
133
+
134
+ export function AppShell({ route, navigate }: { route: Route; navigate: (path: string) => void }) {
135
+ const { theme, toggle } = useTheme();
136
+ const { roots } = useTree();
137
+
138
+ // The listing route's entries come from the server-embedded route JSON (or
139
+ // the navigation fetch) and are seeded into the query cache, so read them
140
+ // back *live* from there: when files are added/removed under the open
141
+ // folder, api.ts invalidates that query and this re-renders with the fresh
142
+ // entries — no route reload, no second fetch racing the invalidation.
143
+ const liveListing = useFolderListing(route.kind === "listing" ? route.path : "");
144
+ const listingRoute = useMemo((): Route | null => {
145
+ if (route.kind !== "listing") return null;
146
+ // A NOT_FOUND refetch means the folder was renamed or deleted on disk
147
+ // after it was opened: show that rather than its stale entries.
148
+ if (liveListing.notFound) {
149
+ return {
150
+ kind: "notfound",
151
+ path: route.path,
152
+ rootName: route.rootName,
153
+ rootDir: route.rootDir,
154
+ };
155
+ }
156
+ return liveListing.entries ? { ...route, entries: liveListing.entries } : route;
157
+ }, [route, liveListing.entries, liveListing.notFound]);
158
+ // Until the tree API answers, trust the count the server-rendered shell
159
+ // embedded so multi-root navigation doesn't flash in after first paint.
160
+ const rootCount = roots?.length ?? shellInfo.rootCount ?? 1;
161
+ // Neither "home" nor a bare "notfound" (before any doc/listing loaded)
162
+ // carries a path/root of its own; fall back to values every consumer below
163
+ // can key off safely.
164
+ const activePath = route.kind === "home" ? "" : route.path;
165
+ const activeRootDir = route.kind === "home" ? "" : (route.rootDir ?? "");
166
+
167
+ const [desktopOpen, setDesktopOpen] = useState(readStoredSidebarOpen);
168
+ const [mobileOpen, setMobileOpen] = useState(false);
169
+
170
+ const [searchOpen, setSearchOpen] = useState(false);
171
+ const [exporting, setExporting] = useState(false);
172
+ const listingWidth = useAtomValue(listingWidthAtom);
173
+ const docWidth = useAtomValue(docWidthAtom);
174
+ const contentRef = useRef<HTMLDivElement>(null);
175
+ const contentWidth = useContentWidth(contentRef);
176
+ const listingMaxWidth = useListingMaxWidth(contentWidth, listingWidth);
177
+ const docMaxWidth = useDocMaxWidth(contentWidth, docWidth);
178
+ const [query, setQuery] = useState("");
179
+ const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS);
180
+ const { data: searchData, isError: searchIsError } = useQuery(
181
+ trpc.searchDocs.queryOptions(
182
+ { query: debouncedQuery },
183
+ { enabled: searchOpen, placeholderData: keepPreviousData, staleTime: 0 },
184
+ ),
185
+ );
186
+ const results = searchIsError ? [] : (searchData?.results ?? []);
187
+
188
+ // Doc content can render asynchronously after this component's own commit
189
+ // (see DocView's onRendered / client/router.ts's initial-route bootstrap
190
+ // effect); TocRail keys its heading scan on this so it re-scans once the
191
+ // article is actually on the page, not just when `route.path` changes.
192
+ const [tocVersion, setTocVersion] = useState(0);
193
+ const bumpTocVersion = useCallback(() => {
194
+ setTocVersion((previous) => previous + 1);
195
+ // The doc module arrives after the browser's own hash jump, so honour a
196
+ // `#heading` in the URL once the headings exist.
197
+ const hash = window.location.hash.slice(1);
198
+ if (hash) {
199
+ const target = document.getElementById(decodeURIComponent(hash));
200
+ target?.scrollIntoView({ block: "start", behavior: "instant" });
201
+ }
202
+ }, []);
203
+
204
+ useEffect(() => {
205
+ try {
206
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, desktopOpen ? "1" : "0");
207
+ } catch {
208
+ // private mode / storage disabled: the choice just won't persist
209
+ }
210
+ }, [desktopOpen]);
211
+
212
+ // The docked sidebar is hidden on folder views and the roots home page.
213
+ const sidebarHidden = route.kind === "listing" || route.kind === "home";
214
+
215
+ // Drawer rows are plain links handled by the router's global click
216
+ // delegation, not a per-row handler, so close the drawer on any navigation.
217
+ useEffect(() => {
218
+ setMobileOpen(false);
219
+ }, [activePath]);
220
+
221
+ // One export at a time: the trigger is disabled while a build runs, and the
222
+ // server queues builds anyway (see startServer).
223
+ const handleExport = useCallback(
224
+ async (format: ExportFormat) => {
225
+ if (route.kind !== "doc" || exporting) return;
226
+ setExporting(true);
227
+ try {
228
+ await exportDocFromViewer({ path: route.path, format });
229
+ } finally {
230
+ setExporting(false);
231
+ }
232
+ },
233
+ [route, exporting],
234
+ );
235
+
236
+ const handleToggleSidebar = useCallback(() => {
237
+ if (window.matchMedia(DESKTOP_MEDIA).matches) {
238
+ // A click here would otherwise silently flip (and persist) the
239
+ // preference for the next doc.
240
+ if (sidebarHidden) return;
241
+ setDesktopOpen((previous) => !previous);
242
+ } else {
243
+ setMobileOpen((previous) => !previous);
244
+ }
245
+ }, [sidebarHidden]);
246
+
247
+ // Global ⌘K / Ctrl+K to open search, from anywhere on the page.
248
+ useEffect(() => {
249
+ function onKeyDown(event: KeyboardEvent) {
250
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
251
+ event.preventDefault();
252
+ setSearchOpen(true);
253
+ }
254
+ }
255
+ window.addEventListener("keydown", onKeyDown);
256
+ return () => window.removeEventListener("keydown", onKeyDown);
257
+ }, []);
258
+
259
+ const handleSelectResult = useCallback(
260
+ (result: SearchResult) => {
261
+ setSearchOpen(false);
262
+ if (result.path) navigate(result.path);
263
+ },
264
+ [navigate],
265
+ );
266
+
267
+ // The sidebar footer counts every doc across all mounted roots.
268
+ const docCount = useMemo(
269
+ () => (roots ?? []).reduce((sum, root) => sum + flattenDocs(root.tree).length, 0),
270
+ [roots],
271
+ );
272
+
273
+ // Prev/next is scoped to the root the open doc lives in, not every mounted root.
274
+ const activeTree = useMemo(() => {
275
+ if (route.kind !== "doc") return null;
276
+ return roots?.find((root) => root.dir === route.rootDir)?.tree ?? null;
277
+ }, [roots, route]);
278
+ const flatDocs = useMemo(() => flattenDocs(activeTree), [activeTree]);
279
+ const { prev, next } = useMemo((): { prev?: PageNavLink; next?: PageNavLink } => {
280
+ if (route.kind !== "doc") return {};
281
+ const index = flatDocs.findIndex((doc) => doc.path === route.path);
282
+ if (index === -1) return {};
283
+ const previousDoc = flatDocs[index - 1];
284
+ const nextDoc = flatDocs[index + 1];
285
+ return {
286
+ prev: previousDoc
287
+ ? { label: stripDocExt(previousDoc.name), href: previousDoc.path }
288
+ : undefined,
289
+ next: nextDoc ? { label: stripDocExt(nextDoc.name), href: nextDoc.path } : undefined,
290
+ };
291
+ }, [flatDocs, route]);
292
+
293
+ function handleExitComplete() {
294
+ if (!location.hash) window.scrollTo({ top: 0, behavior: "instant" });
295
+ }
296
+
297
+ // A folder view is the file navigation, so the docked sidebar would repeat
298
+ // it; hide the column there (and on the roots home page) and let the
299
+ // content take the space.
300
+ const showSidebar = desktopOpen && !sidebarHidden;
301
+
302
+ return (
303
+ <div className="min-h-screen bg-surface-page">
304
+ <TopBar
305
+ homeHref="/"
306
+ hostLabel={location.host}
307
+ sidebar={{ open: showSidebar, onToggle: handleToggleSidebar }}
308
+ search={{ onOpen: () => setSearchOpen(true) }}
309
+ exportDoc={
310
+ route.kind === "doc" && shellInfo.sameMachine
311
+ ? { pending: exporting, onExport: handleExport }
312
+ : undefined
313
+ }
314
+ theme={theme}
315
+ onToggleTheme={toggle}
316
+ />
317
+ <div className="flex items-start">
318
+ <Sidebar
319
+ rootDir={activeRootDir}
320
+ activePath={activePath}
321
+ docCount={docCount}
322
+ singleRoot={rootCount <= 1}
323
+ desktopOpen={showSidebar}
324
+ mobileOpen={mobileOpen}
325
+ onCloseMobile={() => setMobileOpen(false)}
326
+ />
327
+ <main className="flex flex-1 min-w-0 px-8 pt-10 pb-24">
328
+ {/* The resize handles clamp drags to this area, and the max-width
329
+ springs track it, so content never runs under the toc rail. */}
330
+ <div ref={contentRef} data-content-area className="flex flex-1 min-w-0 justify-center">
331
+ <AnimatePresence mode="wait" initial={false} onExitComplete={handleExitComplete}>
332
+ <motion.div
333
+ key={activePath || "/"}
334
+ variants={fadeRise}
335
+ initial="initial"
336
+ animate="enter"
337
+ exit="exit"
338
+ className={
339
+ route.kind === "listing" || (route.kind === "doc" && docMaxWidth)
340
+ ? "w-full"
341
+ : "w-full max-w-prose"
342
+ }
343
+ style={
344
+ route.kind === "listing"
345
+ ? { maxWidth: listingMaxWidth }
346
+ : route.kind === "doc" && docMaxWidth
347
+ ? { maxWidth: docMaxWidth }
348
+ : undefined
349
+ }
350
+ >
351
+ <Breadcrumb items={breadcrumbItems(route, rootCount)} />
352
+ <div className="mt-4">
353
+ {route.kind === "listing" ? (
354
+ listingRoute?.kind === "notfound" ? (
355
+ <NotFoundView route={listingRoute} />
356
+ ) : (
357
+ <ListingView
358
+ route={listingRoute?.kind === "listing" ? listingRoute : route}
359
+ singleRoot={rootCount <= 1}
360
+ />
361
+ )
362
+ ) : route.kind === "doc" ? (
363
+ <DocView
364
+ path={route.path}
365
+ module={docModuleCache.get(route.path)}
366
+ onRendered={bumpTocVersion}
367
+ />
368
+ ) : route.kind === "home" ? (
369
+ <HomeView route={route} />
370
+ ) : (
371
+ <NotFoundView route={route} />
372
+ )}
373
+ </div>
374
+ {route.kind === "doc" && (prev || next) ? (
375
+ <div data-print-hide className="mt-16">
376
+ <PageNav prev={prev} next={next} />
377
+ </div>
378
+ ) : null}
379
+ {route.kind === "doc" ? (
380
+ <Footer
381
+ label={route.rootName + route.path.slice(route.rootDir.length)}
382
+ mtime={route.mtime ?? docMtime(route.path)}
383
+ />
384
+ ) : null}
385
+ </motion.div>
386
+ </AnimatePresence>
387
+ </div>
388
+ {route.kind === "doc" ? <TocRail path={route.path} version={tocVersion} /> : null}
389
+ </main>
390
+ </div>
391
+ <SearchDialog
392
+ open={searchOpen}
393
+ query={query}
394
+ results={results}
395
+ onQueryChange={setQuery}
396
+ onClose={() => setSearchOpen(false)}
397
+ onSelect={handleSelectResult}
398
+ />
399
+ </div>
400
+ );
401
+ }
@@ -0,0 +1,33 @@
1
+ import { Icon } from "../ui/Icon";
2
+
3
+ const MINUTE = 60_000;
4
+ const HOUR = 60 * MINUTE;
5
+ const DAY = 24 * HOUR;
6
+ const WEEK = 7 * DAY;
7
+
8
+ function pluralize(count: number, unit: string): string {
9
+ return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
10
+ }
11
+
12
+ /** "edited just now / N minutes ago / N hours ago / N days ago / a date". */
13
+ function formatEdited(mtime: number | undefined): string {
14
+ if (mtime == null) return "unknown edit time";
15
+ const diff = Date.now() - mtime;
16
+ if (diff < MINUTE) return "edited just now";
17
+ if (diff < HOUR) return `edited ${pluralize(Math.floor(diff / MINUTE), "minute")}`;
18
+ if (diff < DAY) return `edited ${pluralize(Math.floor(diff / HOUR), "hour")}`;
19
+ if (diff < WEEK) return `edited ${pluralize(Math.floor(diff / DAY), "day")}`;
20
+ return `edited ${new Date(mtime).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}`;
21
+ }
22
+
23
+ /** Names the source file below a doc's content — the reader should never lose track of it. */
24
+ export function Footer({ label, mtime }: { label: string; mtime?: number }) {
25
+ return (
26
+ <div className="mt-8 flex items-center gap-2 font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle">
27
+ <Icon name="file-text" size="sm" strokeWidth="light" />
28
+ <span className="font-mono text-[length:var(--size-xs)]">{label}</span>
29
+ <span>·</span>
30
+ <span>{formatEdited(mtime)}</span>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,22 @@
1
+ import type { Route } from "../router";
2
+
3
+ export function NotFoundView({ route }: { route: Extract<Route, { kind: "notfound" }> }) {
4
+ return (
5
+ <div>
6
+ <h1 className="mb-4 font-sans font-bold leading-[1.2] text-[length:var(--size-3xl)] text-text-heading">
7
+ Not found
8
+ </h1>
9
+ <p className="mb-6">
10
+ <code className="rounded-sm border border-border-default bg-surface-card px-1.5 py-0.5 font-mono text-[length:var(--size-xs)] text-text-muted">
11
+ {route.path}
12
+ </code>
13
+ </p>
14
+ <a
15
+ href={route.rootDir ? `${route.rootDir}/` : "/"}
16
+ className="text-[13px] font-semibold text-text-accent hover:text-text-link-hover"
17
+ >
18
+ Back to {route.rootName ?? "home"}
19
+ </a>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,169 @@
1
+ import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
2
+ import { useRef } from "react";
3
+ import { useAtom } from "jotai";
4
+ import { VARIANTS } from "../motion";
5
+ import { Icon } from "../ui/Icon";
6
+ import { ResizeHandle } from "../ui/ResizeHandle";
7
+ import { ListingView } from "../ListingView";
8
+ import { useFolderListing } from "../router";
9
+ import { shortenHome } from "../format";
10
+ import { SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, sidebarWidthAtom } from "../state";
11
+
12
+ const DOCKED_CLASS =
13
+ "shrink-0 sticky top-topbar h-[calc(100vh-var(--topbar-height))] border-r border-border-subtle";
14
+
15
+ function clampWidth(px: number): number {
16
+ return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(px)));
17
+ }
18
+
19
+ /** "/a/b.md" -> "/a/"; "/x.md" -> "/"; a path already ending in "/" is unchanged. */
20
+ function folderOf(path: string): string {
21
+ if (path.endsWith("/")) return path;
22
+ const lastSlash = path.lastIndexOf("/");
23
+ return path.slice(0, lastSlash + 1);
24
+ }
25
+
26
+ function SidebarBody({
27
+ rootDir,
28
+ activePath,
29
+ docCount,
30
+ singleRoot,
31
+ layoutGroupId,
32
+ }: {
33
+ rootDir: string;
34
+ activePath: string;
35
+ docCount: number;
36
+ singleRoot: boolean;
37
+ layoutGroupId: string;
38
+ }) {
39
+ // The roots home page has no path of its own (the mobile drawer can still
40
+ // open there); skip the listing rather than fetch "".
41
+ const folder = activePath ? folderOf(activePath) : "";
42
+ const { entries, error } = useFolderListing(folder);
43
+
44
+ return (
45
+ <>
46
+ {/* Plain <a>: the router's global click delegation handles navigation. */}
47
+ <a
48
+ href={rootDir ? `${rootDir}/` : "/"}
49
+ title={rootDir}
50
+ className="mb-1 flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[length:var(--size-2xs)] text-text-subtle no-underline hover:bg-surface-hover hover:text-text-heading"
51
+ >
52
+ <Icon name="folder-open" size={13} className="shrink-0" />
53
+ <span className="truncate">{rootDir ? shortenHome(rootDir) : "mdxserve"}</span>
54
+ </a>
55
+ {!folder ? null : entries ? (
56
+ // The desktop column and mobile drawer can both be mounted, and both
57
+ // contain a row with layoutId="sidebar-active", so each gets its own
58
+ // namespace here.
59
+ <LayoutGroup id={layoutGroupId}>
60
+ <ListingView
61
+ route={{ path: folder, rootDir, entries }}
62
+ mode="sidebar"
63
+ activePath={activePath}
64
+ singleRoot={singleRoot}
65
+ />
66
+ </LayoutGroup>
67
+ ) : error ? (
68
+ <p className="px-2 text-[13px] text-text-subtle">Couldn't load this folder.</p>
69
+ ) : null}
70
+ <div className="mt-4 flex items-center gap-2 border-t border-border-subtle px-2 pt-3 text-[13px] leading-normal font-medium text-text-subtle">
71
+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-teal-400" />
72
+ Watching {docCount} {docCount === 1 ? "file" : "files"}
73
+ </div>
74
+ </>
75
+ );
76
+ }
77
+
78
+ export function Sidebar({
79
+ rootDir,
80
+ activePath,
81
+ docCount,
82
+ singleRoot,
83
+ desktopOpen,
84
+ mobileOpen,
85
+ onCloseMobile,
86
+ }: {
87
+ rootDir: string;
88
+ activePath: string;
89
+ docCount: number;
90
+ singleRoot: boolean;
91
+ desktopOpen: boolean;
92
+ mobileOpen: boolean;
93
+ onCloseMobile: () => void;
94
+ }) {
95
+ const [width, setWidth] = useAtom(sidebarWidthAtom);
96
+ const asideRef = useRef<HTMLDivElement>(null);
97
+
98
+ return (
99
+ <>
100
+ {/* Mobile overlay: fixed drawer + scrim, closed by default. */}
101
+ <AnimatePresence>
102
+ {mobileOpen ? (
103
+ <motion.div
104
+ key="sidebar-scrim"
105
+ data-print-hide
106
+ {...VARIANTS.scrim}
107
+ onClick={onCloseMobile}
108
+ className="fixed inset-0 z-[var(--z-scrim)] bg-[var(--scrim)] md:hidden"
109
+ />
110
+ ) : null}
111
+ </AnimatePresence>
112
+ <AnimatePresence>
113
+ {mobileOpen ? (
114
+ <motion.aside
115
+ key="sidebar-panel"
116
+ data-print-hide
117
+ {...VARIANTS.pop}
118
+ // Rows are plain links handled by the router, and tapping the already
119
+ // active doc never changes the route, so close on any link tap here.
120
+ onClick={(event) => {
121
+ if ((event.target as HTMLElement).closest("a[href]")) onCloseMobile();
122
+ }}
123
+ className={
124
+ "fixed inset-y-0 left-0 z-[var(--z-modal)] overflow-y-auto border-r border-border-subtle bg-surface-raised px-3 py-6 shadow-lg md:hidden w-sidebar"
125
+ }
126
+ >
127
+ <SidebarBody
128
+ rootDir={rootDir}
129
+ activePath={activePath}
130
+ docCount={docCount}
131
+ singleRoot={singleRoot}
132
+ layoutGroupId="sidebar-mobile"
133
+ />
134
+ </motion.aside>
135
+ ) : null}
136
+ </AnimatePresence>
137
+
138
+ {/* Desktop docked column. */}
139
+ {desktopOpen ? (
140
+ <aside
141
+ ref={asideRef}
142
+ data-print-hide
143
+ className={"relative hidden md:block " + DOCKED_CLASS}
144
+ style={{ width: clampWidth(width) }}
145
+ >
146
+ <div className="h-full overflow-y-auto px-3 py-6">
147
+ <SidebarBody
148
+ rootDir={rootDir}
149
+ activePath={activePath}
150
+ docCount={docCount}
151
+ singleRoot={singleRoot}
152
+ layoutGroupId="sidebar-desktop"
153
+ />
154
+ </div>
155
+ {/* Sibling of the scroller, not inside it, so overflow-y-auto can't clip it. */}
156
+ <ResizeHandle
157
+ side="right"
158
+ anchored
159
+ container={asideRef}
160
+ onResize={setWidth}
161
+ label="Resize sidebar"
162
+ minWidth={SIDEBAR_MIN_WIDTH}
163
+ maxWidth={SIDEBAR_MAX_WIDTH}
164
+ />
165
+ </aside>
166
+ ) : null}
167
+ </>
168
+ );
169
+ }
@@ -0,0 +1,65 @@
1
+ import { motion } from "framer-motion";
2
+ import { useAtomValue } from "jotai";
3
+ import { useCallback, useRef, useState } from "react";
4
+ import { DocView } from "../DocView";
5
+ import { fadeRise } from "../motion";
6
+ import { useTheme } from "../theme";
7
+ import { docModuleCache } from "../doc-module-cache";
8
+ import { docWidthAtom } from "../state";
9
+ import { Footer } from "./Footer";
10
+ import { TocRail } from "./TocRail";
11
+ import { TopBar } from "./TopBar";
12
+ import { useContentWidth, useDocMaxWidth } from "./use-doc-width";
13
+ import type { StandaloneMeta } from "../standalone-entry";
14
+
15
+ /**
16
+ * The shell rendered by a `mdxserve export` output: one doc, no server. A
17
+ * pared-down `AppShell` — same width spring, resize handles, TOC rail, and
18
+ * enter animation, but no sidebar, search, breadcrumb, prev/next, or route
19
+ * transitions (there is only ever one route, baked in at build time).
20
+ */
21
+ export function StandaloneShell({ meta }: { meta: StandaloneMeta }) {
22
+ const { theme, toggle } = useTheme();
23
+ const [tocVersion, setTocVersion] = useState(0);
24
+ const docWidth = useAtomValue(docWidthAtom);
25
+ const contentRef = useRef<HTMLDivElement>(null);
26
+ const contentWidth = useContentWidth(contentRef);
27
+ const docMaxWidth = useDocMaxWidth(contentWidth, docWidth);
28
+
29
+ // Mirrors AppShell's bumpTocVersion: re-scan headings once the article is
30
+ // on the page, and honour a #hash the browser jumped past before it existed.
31
+ const bumpTocVersion = useCallback(() => {
32
+ setTocVersion((version) => version + 1);
33
+ const hash = window.location.hash.slice(1);
34
+ if (hash) {
35
+ document
36
+ .getElementById(decodeURIComponent(hash))
37
+ ?.scrollIntoView({ block: "start", behavior: "instant" });
38
+ }
39
+ }, []);
40
+
41
+ return (
42
+ <div className="min-h-screen bg-surface-page">
43
+ <TopBar theme={theme} onToggleTheme={toggle} hostLabel={meta.label} />
44
+ <main className="flex flex-1 min-w-0 px-8 pt-10 pb-24">
45
+ <div ref={contentRef} data-content-area className="flex flex-1 min-w-0 justify-center">
46
+ <motion.div
47
+ variants={fadeRise}
48
+ initial="initial"
49
+ animate="enter"
50
+ className={docMaxWidth ? "w-full" : "w-full max-w-prose"}
51
+ style={docMaxWidth ? { maxWidth: docMaxWidth } : undefined}
52
+ >
53
+ <DocView
54
+ path={meta.path}
55
+ module={docModuleCache.get(meta.path)}
56
+ onRendered={bumpTocVersion}
57
+ />
58
+ <Footer label={meta.label} mtime={meta.mtime} />
59
+ </motion.div>
60
+ </div>
61
+ <TocRail path={meta.path} version={tocVersion} />
62
+ </main>
63
+ </div>
64
+ );
65
+ }