@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,452 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
2
+ import { isCancelledError, useQuery } from "@tanstack/react-query";
3
+ import { isTRPCClientError } from "@trpc/client";
4
+ import type { inferRouterOutputs } from "@trpc/server";
5
+ import { queryClient, trpc } from "./api";
6
+ // Type-only: see the comment in client/api.ts — src/ is not served by Vite.
7
+ import type { AppRouter } from "../src/api/router";
8
+
9
+ type RouterOutputs = inferRouterOutputs<AppRouter>;
10
+ type DocTreeOutput = RouterOutputs["getDocTree"];
11
+ type FolderListingOutput = RouterOutputs["getFolderListing"];
12
+
13
+ export type ListingEntry = FolderListingOutput["entries"][number];
14
+
15
+ // Kept in sync with the same type in src/roots/root-info.ts — client code
16
+ // can't import from src/, so this is a deliberate copy. `dir` has no
17
+ // trailing slash.
18
+ export interface RootInfo {
19
+ name: string;
20
+ dir: string;
21
+ }
22
+
23
+ export type Route =
24
+ | { kind: "home"; roots: RootInfo[] }
25
+ | { kind: "listing"; path: string; rootName: string; rootDir: string; entries: ListingEntry[] }
26
+ | { kind: "doc"; path: string; rootName: string; rootDir: string; mtime?: number }
27
+ | { kind: "notfound"; path: string; rootName?: string; rootDir?: string };
28
+
29
+ // Derived from the router's own output schema (src/listing/controller.ts's
30
+ // treeNodeSchema is itself typed against src/listing/tree.ts's TreeNode), so this
31
+ // can't drift from the server the way a hand-copied interface could.
32
+ export type TreeNode = DocTreeOutput["roots"][number]["nodes"][number];
33
+
34
+ /** A mounted root together with its doc tree. */
35
+ export interface RootTree extends RootInfo {
36
+ tree: TreeNode[];
37
+ }
38
+
39
+ // The doc-module cache lives in client/doc-module-cache.ts (so an exported
40
+ // file can use it without this module's tRPC graph); re-exported here for the
41
+ // live viewer's callers.
42
+ import { docModuleCache, type DocModuleState } from "./doc-module-cache";
43
+ import { decodeRoutePath } from "./route-path";
44
+ export { docModuleCache, type DocModuleState };
45
+ const docModulePromises = new Map<string, Promise<void>>();
46
+
47
+ function ensureDocModule(path: string): Promise<void> {
48
+ if (docModuleCache.has(path)) return Promise.resolve();
49
+ const pending = docModulePromises.get(path);
50
+ if (pending) return pending;
51
+
52
+ // Vite serves absolute filesystem paths through its /@fs/ scheme.
53
+ const promise = import(/* @vite-ignore */ "/@fs" + path)
54
+ .then((mod: { default?: ComponentType }) => {
55
+ if (!mod.default) {
56
+ docModuleCache.set(path, { status: "error", message: `${path} has no default export.` });
57
+ } else {
58
+ docModuleCache.set(path, { status: "ok", Component: mod.default });
59
+ }
60
+ })
61
+ .catch((error: unknown) => {
62
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
63
+ docModuleCache.set(path, { status: "error", message });
64
+ })
65
+ .finally(() => {
66
+ docModulePromises.delete(path);
67
+ });
68
+
69
+ docModulePromises.set(path, promise);
70
+ return promise;
71
+ }
72
+
73
+ /**
74
+ * What the server-rendered shell already knows about the roots, seeded by
75
+ * entry.tsx before React mounts so multi-root navigation (home crumb, `..`
76
+ * row) doesn't flash in or out while the tree query is still loading.
77
+ */
78
+ export const shellInfo: { rootCount: number | null; sameMachine: boolean } = {
79
+ rootCount: null,
80
+ sameMachine: false,
81
+ };
82
+
83
+ function mapRoots(data: DocTreeOutput): RootTree[] {
84
+ return data.roots.map((root) => ({ name: root.name, dir: root.dir, tree: root.nodes }));
85
+ }
86
+
87
+ /** Fetch the doc tree once and cache it; subsequent calls reuse the react-query cache. */
88
+ export async function loadTree(): Promise<{ roots: RootTree[] | null }> {
89
+ try {
90
+ const data = await queryClient.ensureQueryData(
91
+ trpc.getDocTree.queryOptions({}, { staleTime: Infinity }),
92
+ );
93
+ return { roots: mapRoots(data) };
94
+ } catch {
95
+ // Leave the previous (possibly absent) tree in place; callers can retry.
96
+ const cached = queryClient.getQueryData(trpc.getDocTree.queryKey({}));
97
+ return { roots: cached ? mapRoots(cached) : null };
98
+ }
99
+ }
100
+
101
+ /** React hook for the shared doc tree; triggers the initial fetch on first use. */
102
+ export function useTree(): { roots: RootTree[] | null } {
103
+ const { data } = useQuery(trpc.getDocTree.queryOptions({}, { staleTime: Infinity }));
104
+ // Referentially stable across renders (consumers use this in deps arrays)
105
+ // as long as the underlying query data hasn't changed.
106
+ return useMemo(() => ({ roots: data ? mapRoots(data) : null }), [data]);
107
+ }
108
+
109
+ /** Look up a doc's mtime in the cached tree (undefined if not loaded/found). */
110
+ export function docMtime(path: string): number | undefined {
111
+ function find(nodes: TreeNode[]): number | undefined {
112
+ for (const node of nodes) {
113
+ if (!node.isDir && node.path === path) return node.mtime;
114
+ if (node.children) {
115
+ const found = find(node.children);
116
+ if (found !== undefined) return found;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
121
+ const data = queryClient.getQueryData(trpc.getDocTree.queryKey({}));
122
+ if (!data) return undefined;
123
+ for (const root of data.roots) {
124
+ const found = find(root.nodes);
125
+ if (found !== undefined) return found;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ /**
131
+ * The mounted root containing `path` (longest `dir` prefix match), or
132
+ * undefined before the tree loads or when `path` lies outside every root.
133
+ */
134
+ export function rootFor(path: string): RootInfo | undefined {
135
+ const data = queryClient.getQueryData(trpc.getDocTree.queryKey({}));
136
+ if (!data) return undefined;
137
+ let best: RootInfo | undefined;
138
+ for (const root of data.roots) {
139
+ if (path === root.dir || path.startsWith(`${root.dir}/`)) {
140
+ if (!best || root.dir.length > best.dir.length) best = { name: root.name, dir: root.dir };
141
+ }
142
+ }
143
+ return best;
144
+ }
145
+
146
+ export interface FolderListing {
147
+ /** null until the first successful fetch. */
148
+ entries: ListingEntry[] | null;
149
+ error: boolean;
150
+ /**
151
+ * The server answered NOT_FOUND on a refetch: the folder was renamed or
152
+ * deleted on disk after it was opened, so `entries` (kept for
153
+ * stale-while-revalidate) no longer describe anything real.
154
+ */
155
+ notFound: boolean;
156
+ }
157
+
158
+ /**
159
+ * Query options for a folder's listing: disabled for the empty path (the
160
+ * roots home page has no folder of its own), always refetched on mount (an
161
+ * h1 edit only ships as a module HMR update, not a listing-changed event, so
162
+ * a cached listing can be stale the moment it's shown), and never cached
163
+ * across navigations (`staleTime: 0`) so re-opening a folder mid-session
164
+ * always sees the latest disk state.
165
+ */
166
+ export function listingQueryOptions(folder: string) {
167
+ const base = trpc.getFolderListing.queryOptions(
168
+ { path: folder },
169
+ { enabled: folder !== "", staleTime: 0, refetchOnMount: "always" },
170
+ );
171
+ const baseQueryFn = base.queryFn;
172
+ return {
173
+ ...base,
174
+ queryFn: async (context: Parameters<NonNullable<typeof baseQueryFn>>[0]) => {
175
+ if (!baseQueryFn) throw new Error("getFolderListing queryOptions did not provide a queryFn");
176
+ const data = await baseQueryFn(context);
177
+ // The server can resolve/normalize the requested path (e.g. trailing
178
+ // slash quirks); make sure whoever asked under `folder` sees it too.
179
+ if (data.path !== folder) {
180
+ queryClient.setQueryData(trpc.getFolderListing.queryKey({ path: data.path }), data);
181
+ }
182
+ return data;
183
+ },
184
+ };
185
+ }
186
+
187
+ /** Seed the cache for a folder from data already fetched elsewhere (e.g. the initial route). */
188
+ export function seedListing(listing: FolderListingOutput): void {
189
+ queryClient.setQueryData(trpc.getFolderListing.queryKey({ path: listing.path }), listing);
190
+ }
191
+
192
+ /**
193
+ * Fetch a folder's listing through the cache. A watcher event that lands
194
+ * while this is in flight makes api.ts invalidate the same query with
195
+ * `cancelRefetch`, which rejects *this* caller's promise with a
196
+ * CancelledError even though the refetch it started will succeed — so try
197
+ * once more rather than reporting a perfectly good folder as not found.
198
+ */
199
+ async function fetchListing(folder: string): Promise<FolderListingOutput> {
200
+ try {
201
+ return await queryClient.fetchQuery(listingQueryOptions(folder));
202
+ } catch (error) {
203
+ if (!isCancelledError(error)) throw error;
204
+ return queryClient.fetchQuery(listingQueryOptions(folder));
205
+ }
206
+ }
207
+
208
+ /** React hook for a single folder's listing; triggers the initial fetch on first use. */
209
+ export function useFolderListing(folder: string): FolderListing {
210
+ const { data, isError, error } = useQuery(listingQueryOptions(folder));
211
+ // Stale-while-revalidate: keep showing the last good entries on a failed
212
+ // refetch rather than blanking the listing out — unless the server says
213
+ // the folder itself is gone, which the caller must surface.
214
+ const notFound = isError && isTRPCClientError(error) && error.data?.code === "NOT_FOUND";
215
+ return { entries: data?.entries ?? null, error: isError && !data, notFound };
216
+ }
217
+
218
+ function titleFor(route: Route): string {
219
+ if (route.kind === "home") return "mdxserve";
220
+ if (route.kind === "listing") return `${route.rootName}${route.path.slice(route.rootDir.length)}`;
221
+ if (route.kind === "doc") {
222
+ const segments = route.path.split("/").filter(Boolean);
223
+ return segments[segments.length - 1] ?? route.rootName;
224
+ }
225
+ return "Not found";
226
+ }
227
+
228
+ /** The `rootName`/`rootDir` a route carries, if any (every kind but `home`). */
229
+ function routeRootFields(route: Route): { rootName?: string; rootDir?: string } {
230
+ if (route.kind === "home") return {};
231
+ return { rootName: route.rootName, rootDir: route.rootDir };
232
+ }
233
+
234
+ /**
235
+ * Drives the client-side SPA: resolves a path to a `Route` (querying the
236
+ * folder listing or dynamic-importing a doc module as needed), intercepts
237
+ * same-origin folder/doc link clicks so navigation never triggers a full
238
+ * page load, and keeps `history`/`document.title` in sync.
239
+ */
240
+ export function useRouter(initialRoute: Route) {
241
+ const [route, setRoute] = useState<Route>(initialRoute);
242
+ const routeRef = useRef(route);
243
+ routeRef.current = route;
244
+
245
+ const loadRoute = useCallback(async (rawPath: string): Promise<Route | null> => {
246
+ // Link clicks and popstate hand over `URL.pathname`, which is
247
+ // percent-encoded; the server-rendered initial route is not. Decode
248
+ // once here so `route.path` (the doc module cache key, the footer
249
+ // label, and every API call's `path`) has one form.
250
+ const path = decodeRoutePath(rawPath);
251
+ if (path === "/") {
252
+ // Mirrors the server's own "/" handling: a single root redirects
253
+ // straight to its listing; otherwise show the roots home page.
254
+ const { roots } = await loadTree();
255
+ if (roots && roots.length === 1) return loadRoute(`${roots[0].dir}/`);
256
+ return {
257
+ kind: "home",
258
+ roots: (roots ?? []).map((rootInfo) => ({ name: rootInfo.name, dir: rootInfo.dir })),
259
+ };
260
+ }
261
+
262
+ if (path.endsWith("/")) {
263
+ try {
264
+ const data = await fetchListing(path);
265
+ return {
266
+ kind: "listing",
267
+ path: data.path,
268
+ rootName: data.rootName,
269
+ rootDir: data.rootDir,
270
+ entries: data.entries,
271
+ };
272
+ } catch {
273
+ return { kind: "notfound", path, ...routeRootFields(routeRef.current) };
274
+ }
275
+ }
276
+
277
+ if (path.endsWith(".md") || path.endsWith(".mdx")) {
278
+ await ensureDocModule(path);
279
+ const root = rootFor(path);
280
+ const fallback = routeRootFields(routeRef.current);
281
+ return {
282
+ kind: "doc",
283
+ path,
284
+ rootName: root?.name ?? fallback.rootName ?? "",
285
+ rootDir: root?.dir ?? fallback.rootDir ?? "",
286
+ };
287
+ }
288
+
289
+ // Not a listing or a doc: let the browser handle it as a normal navigation.
290
+ window.location.assign(path);
291
+ return null;
292
+ }, []);
293
+
294
+ const navigate = useCallback(
295
+ (path: string) => {
296
+ loadRoute(path).then((next) => {
297
+ if (!next) return;
298
+ // Push the path the route actually resolved to: with a single root,
299
+ // "/" resolves to that root's listing (mirroring the server's 302),
300
+ // and the address bar should say so.
301
+ history.pushState({}, "", next.kind === "home" ? "/" : next.path);
302
+ document.title = titleFor(next);
303
+ setRoute(next);
304
+ });
305
+ },
306
+ [loadRoute],
307
+ );
308
+
309
+ // The initial route's data comes straight from the server-embedded JSON, but a
310
+ // doc route still needs its module dynamic-imported before DocView has anything
311
+ // to render. Kick that off once on mount and force a re-render when it lands.
312
+ useEffect(() => {
313
+ if (initialRoute.kind !== "doc") return;
314
+ let cancelled = false;
315
+ ensureDocModule(initialRoute.path).then(() => {
316
+ if (cancelled) return;
317
+ setRoute((current) =>
318
+ current.kind === "doc" && current.path === initialRoute.path ? { ...current } : current,
319
+ );
320
+ });
321
+ return () => {
322
+ cancelled = true;
323
+ };
324
+ // Intentionally run once: this only concerns the route the page booted with.
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps
326
+ }, []);
327
+
328
+ useEffect(() => {
329
+ document.title = titleFor(initialRoute);
330
+ // The server embeds the initial listing's entries straight into the page,
331
+ // but useFolderListing consumers still need them in the shared cache.
332
+ if (initialRoute.kind === "listing") {
333
+ seedListing({
334
+ path: initialRoute.path,
335
+ rootName: initialRoute.rootName,
336
+ rootDir: initialRoute.rootDir,
337
+ entries: initialRoute.entries,
338
+ });
339
+ }
340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
341
+ }, []);
342
+
343
+ useEffect(() => {
344
+ function onPopState() {
345
+ loadRoute(location.pathname).then((next) => {
346
+ if (!next) return;
347
+ document.title = titleFor(next);
348
+ setRoute(next);
349
+ });
350
+ }
351
+ // We scroll to the top ourselves once the exit transition completes;
352
+ // stop the browser from restoring the old scroll offset over it.
353
+ if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual";
354
+ window.addEventListener("popstate", onPopState);
355
+ return () => window.removeEventListener("popstate", onPopState);
356
+ }, [loadRoute]);
357
+
358
+ useEffect(() => {
359
+ function onClick(event: MouseEvent) {
360
+ if (event.defaultPrevented || event.button !== 0) return;
361
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
362
+
363
+ const target = event.target as Element | null;
364
+ const anchor = target?.closest("a");
365
+ if (!anchor) return;
366
+ if (anchor.target || anchor.hasAttribute("download")) return;
367
+
368
+ const href = anchor.getAttribute("href");
369
+ if (!href) return;
370
+
371
+ let url: URL;
372
+ try {
373
+ url = new URL(href, window.location.href);
374
+ } catch {
375
+ return;
376
+ }
377
+ if (url.origin !== window.location.origin) return;
378
+
379
+ // An in-page anchor (#toc-entry) on the current page: let the browser
380
+ // handle the scroll natively instead of intercepting as a navigation.
381
+ if (url.hash && url.pathname === window.location.pathname) return;
382
+
383
+ const pathname = url.pathname;
384
+ if (!(pathname.endsWith("/") || pathname.endsWith(".md") || pathname.endsWith(".mdx")))
385
+ return;
386
+
387
+ event.preventDefault();
388
+ navigate(pathname);
389
+ }
390
+
391
+ document.addEventListener("click", onClick);
392
+ return () => document.removeEventListener("click", onClick);
393
+ }, [navigate]);
394
+
395
+ // Files added/removed under the open folder are handled by api.ts: the
396
+ // watcher's `mdxserve:listing-changed` event invalidates that folder's
397
+ // query, and AppShell renders the listing route from the live query, so
398
+ // there is nothing route-level to do here.
399
+
400
+ // A mounted root can be added or removed at runtime (api.ts re-dispatches
401
+ // the ws event as a window CustomEvent once it's invalidated the tree/
402
+ // listing queries). If the *current* route lives under a root that just
403
+ // got removed, its doc module/listing no longer describe anything real —
404
+ // navigate home rather than leaving a dead page on screen. Adding a root
405
+ // or removing an unrelated one needs no action here: HomeView re-renders
406
+ // itself from the live tree query.
407
+ useEffect(() => {
408
+ function onRootsChanged(event: Event) {
409
+ const detail = (
410
+ event as CustomEvent<{ added: string[]; removed: string[]; roots: RootInfo[] }>
411
+ ).detail;
412
+ const removedDirs = detail?.removed ?? [];
413
+ if (removedDirs.length === 0) return;
414
+
415
+ const current = route;
416
+ if (current.kind === "home") return;
417
+ const { rootDir } = routeRootFields(current);
418
+ const removedDir = removedDirs.find(
419
+ (dir) => dir === rootDir || current.path === dir || current.path.startsWith(`${dir}/`),
420
+ );
421
+ if (!removedDir) return;
422
+
423
+ for (const key of docModuleCache.keys()) {
424
+ if (key === removedDir || key.startsWith(`${removedDir}/`)) docModuleCache.delete(key);
425
+ }
426
+
427
+ // Route off the event's own `roots` list rather than calling
428
+ // navigate("/"): that re-derives the destination from the getDocTree
429
+ // query cache via loadTree(), which api.ts invalidated moments ago but
430
+ // may not have finished refetching yet — a stale read there would still
431
+ // contain the root that was just removed and redirect straight into
432
+ // its now-404 listing. `detail.roots` is the server's ground truth for
433
+ // what's mounted *right now*, so use that instead.
434
+ const remaining = detail.roots;
435
+ if (remaining.length === 1) {
436
+ navigate(`${remaining[0].dir}/`);
437
+ return;
438
+ }
439
+ const homeRoute: Route = {
440
+ kind: "home",
441
+ roots: remaining.map((info) => ({ name: info.name, dir: info.dir })),
442
+ };
443
+ history.pushState({}, "", "/");
444
+ document.title = titleFor(homeRoute);
445
+ setRoute(homeRoute);
446
+ }
447
+ window.addEventListener("mdxserve:roots-changed", onRootsChanged);
448
+ return () => window.removeEventListener("mdxserve:roots-changed", onRootsChanged);
449
+ }, [route, navigate]);
450
+
451
+ return { route, navigate };
452
+ }