@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.
- 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 +113 -5
- 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
|
+
}
|