@karimsa/mdxserve 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +115 -5
- package/skills/mdxserve/SKILL.md +178 -0
package/client/router.ts
ADDED
|
@@ -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
|
+
}
|