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