seemore 1.0.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.
@@ -0,0 +1,90 @@
1
+ import { useEffect, type ComponentProps, type ReactNode } from 'react';
2
+ import {
3
+ SidebarFolder as BaseFolder,
4
+ SidebarFolderContent as BaseFolderContent,
5
+ SidebarFolderLink as BaseFolderLink,
6
+ SidebarFolderTrigger as BaseFolderTrigger,
7
+ SidebarItem as BaseItem,
8
+ SidebarSeparator as BaseSeparator,
9
+ SidebarViewport,
10
+ useSidebar,
11
+ } from 'fumadocs-ui/components/sidebar/base';
12
+ import { createPageTreeRenderer } from 'fumadocs-ui/components/sidebar/page-tree';
13
+ import { feature } from '../lib/features.js';
14
+ import { useRouteUrl } from '../router.js';
15
+
16
+ /**
17
+ * `components/sidebar/base` are behaviour-only primitives: they handle folder state, active
18
+ * tracking and auto-scroll, and render with no classes at all. Every class is the layout's
19
+ * to supply, which is what these wrappers do — fumadocs' own layout does the same.
20
+ */
21
+ type ItemProps = ComponentProps<typeof BaseItem>;
22
+ type FolderLinkProps = ComponentProps<typeof BaseFolderLink>;
23
+ type FolderTriggerProps = ComponentProps<typeof BaseFolderTrigger>;
24
+ type FolderContentProps = ComponentProps<typeof BaseFolderContent>;
25
+ type SeparatorProps = ComponentProps<typeof BaseSeparator>;
26
+ type FolderProps = ComponentProps<typeof BaseFolder>;
27
+
28
+ const styled = {
29
+ SidebarItem: (props: ItemProps) => <BaseItem {...props} className="seemore-sidebar-link" />,
30
+ SidebarFolder: (props: FolderProps) => <BaseFolder {...props} className="seemore-sidebar-folder" />,
31
+ SidebarFolderLink: (props: FolderLinkProps) => (
32
+ <BaseFolderLink {...props} className="seemore-sidebar-link seemore-sidebar-folder-label" />
33
+ ),
34
+ // Must be a flex row: the chevron the primitive appends positions itself with `ms-auto`.
35
+ SidebarFolderTrigger: (props: FolderTriggerProps) => (
36
+ <BaseFolderTrigger {...props} className="seemore-sidebar-link seemore-sidebar-folder-label" />
37
+ ),
38
+ SidebarFolderContent: (props: FolderContentProps) => (
39
+ <BaseFolderContent {...props} className="seemore-sidebar-folder-content" />
40
+ ),
41
+ SidebarSeparator: (props: SeparatorProps) => <BaseSeparator {...props} className="seemore-sidebar-separator" />,
42
+ };
43
+
44
+ const renderPageTree = createPageTreeRenderer(styled);
45
+
46
+ export function Sidebar({ children }: { children?: ReactNode }) {
47
+ // Below `md` the sidebar is a drawer, and the header's trigger is what opens it. Without
48
+ // reading that state the trigger is decorative: the panel is hidden by CSS alone.
49
+ const { open, setOpen } = useSidebar();
50
+ const url = useRouteUrl();
51
+
52
+ // Following a link should not leave the drawer covering the page you asked for.
53
+ useEffect(() => {
54
+ setOpen(false);
55
+ }, [url, setOpen]);
56
+
57
+ // Called during render, never inside `useMemo`: the renderer reads the tree context and
58
+ // calls hooks of its own.
59
+ const rendered = renderPageTree({
60
+ Folder: feature('navigation.sections')
61
+ ? ({ item, children }) => (
62
+ // Top-level entries read as headed groups rather than collapsible folders.
63
+ <BaseFolder collapsible={false} defaultOpen className="seemore-sidebar-folder">
64
+ <BaseSeparator className="seemore-sidebar-separator">{item.name}</BaseSeparator>
65
+ <BaseFolderContent className="seemore-sidebar-folder-content">{children}</BaseFolderContent>
66
+ </BaseFolder>
67
+ )
68
+ : undefined,
69
+ });
70
+
71
+ return (
72
+ <>
73
+ {open ? (
74
+ <button
75
+ type="button"
76
+ className="seemore-sidebar-backdrop"
77
+ aria-label="Close navigation"
78
+ onClick={() => setOpen(false)}
79
+ />
80
+ ) : undefined}
81
+
82
+ <div className="seemore-sidebar-column" data-open={open}>
83
+ <aside className="seemore-sidebar" aria-label="Documentation navigation">
84
+ <SidebarViewport>{rendered}</SidebarViewport>
85
+ </aside>
86
+ {children}
87
+ </div>
88
+ </>
89
+ );
90
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { TOCProvider, TOCScrollArea, useActiveAnchor, useTOCItems } from 'fumadocs-ui/components/toc';
3
+ import { TOCEmpty, TOCItem, TOCItems } from 'fumadocs-ui/components/toc/default';
4
+ import type { TocEntry } from '../../shared/types.js';
5
+ import { feature } from '../lib/features.js';
6
+
7
+ export function TocProvider({ toc, children }: { toc: TocEntry[]; children: React.ReactNode }) {
8
+ return <TOCProvider toc={toc}>{children}</TOCProvider>;
9
+ }
10
+
11
+ export function Toc() {
12
+ const items = useTOCItems();
13
+ if (items.length === 0) return <nav className="seemore-toc" aria-label="On this page" />;
14
+
15
+ return (
16
+ <nav className="seemore-toc" aria-label="On this page">
17
+ <p className="seemore-toc-title">On this page</p>
18
+ <TOCScrollArea>
19
+ <TocBody />
20
+ </TOCScrollArea>
21
+ </nav>
22
+ );
23
+ }
24
+
25
+ /** `toc.integrate`: the same items, rendered inside the sidebar instead of its own rail. */
26
+ export function IntegratedToc() {
27
+ const items = useTOCItems();
28
+ if (items.length === 0) return null;
29
+ return (
30
+ <div className="seemore-toc-integrated">
31
+ <TocBody />
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function TocBody() {
37
+ const items = useTOCItems();
38
+ const active = useActiveAnchor();
39
+ const container = useRef<HTMLDivElement>(null);
40
+
41
+ // `toc.follow`: keep the active entry visible as the page scrolls.
42
+ useEffect(() => {
43
+ if (!feature('toc.follow') || active === undefined || container.current === null) return;
44
+ const element = container.current.querySelector(`a[href="#${CSS.escape(active)}"]`);
45
+ element?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
46
+ }, [active]);
47
+
48
+ if (items.length === 0) return <TOCEmpty />;
49
+
50
+ return (
51
+ <div ref={container}>
52
+ <TOCItems>
53
+ {items.map((item) => (
54
+ <TOCItem key={item.url} item={item} />
55
+ ))}
56
+ </TOCItems>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,7 @@
1
+ import { config } from 'virtual:seemore/config';
2
+ import type { Feature } from '../../shared/types.js';
3
+
4
+ /** Feature flags are resolved node-side; the browser only reads them. */
5
+ export function feature(name: Feature): boolean {
6
+ return config.features[name] === true;
7
+ }
@@ -0,0 +1,81 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { getRoutes, subscribeRoutes } from 'virtual:seemore/routes';
3
+ import type { PageModule, RouteEntry } from '../../shared/types.js';
4
+
5
+ /**
6
+ * One module cache shared by the router, the hover prefetch and the prerender driver.
7
+ *
8
+ * The promise is annotated with `status`/`value`, the convention React's `use()` reads, so a
9
+ * module that is already loaded renders synchronously — which is what lets `renderToString`
10
+ * produce a complete page with no Suspense fallback in the output.
11
+ */
12
+ type Tracked = Promise<PageModule> & {
13
+ status?: 'pending' | 'fulfilled' | 'rejected';
14
+ value?: PageModule;
15
+ reason?: unknown;
16
+ };
17
+
18
+ const cache = new Map<string, Tracked>();
19
+
20
+ let index = buildIndex();
21
+
22
+ // The route list is replaced whenever the corpus changes, so the lookup is rebuilt with it
23
+ // and pages that no longer exist stop holding on to their modules.
24
+ subscribeRoutes(() => {
25
+ index = buildIndex();
26
+ // Deleting the current entry while iterating a Map is well defined.
27
+ for (const url of cache.keys()) {
28
+ if (!index.has(url)) cache.delete(url);
29
+ }
30
+ });
31
+
32
+ function buildIndex(): Map<string, RouteEntry> {
33
+ return new Map(getRoutes().map((entry) => [entry.url, entry]));
34
+ }
35
+
36
+ export function routeEntries(): RouteEntry[] {
37
+ return getRoutes();
38
+ }
39
+
40
+ export function findRoute(url: string): RouteEntry | undefined {
41
+ return index.get(url);
42
+ }
43
+
44
+ /** Subscribe a component to corpus changes; re-renders on create, rename and delete. */
45
+ export function useRouteEntry(url: string): RouteEntry | undefined {
46
+ const entries = useSyncExternalStore(subscribeRoutes, getRoutes, getRoutes);
47
+ return entries.find((entry) => entry.url === url);
48
+ }
49
+
50
+ export function loadPage(entry: RouteEntry): Tracked {
51
+ const existing = cache.get(entry.url);
52
+ if (existing !== undefined) return existing;
53
+
54
+ const promise = entry.load() as Tracked;
55
+ promise.status = 'pending';
56
+ promise.then(
57
+ (value) => {
58
+ promise.status = 'fulfilled';
59
+ promise.value = value;
60
+ },
61
+ (reason: unknown) => {
62
+ promise.status = 'rejected';
63
+ promise.reason = reason;
64
+ },
65
+ );
66
+
67
+ cache.set(entry.url, promise);
68
+ return promise;
69
+ }
70
+
71
+ /** Load a page ahead of rendering it. Used by prerender and by the hover prefetch. */
72
+ export async function preloadPage(url: string): Promise<PageModule | undefined> {
73
+ const entry = findRoute(url);
74
+ if (entry === undefined) return undefined;
75
+ return await loadPage(entry);
76
+ }
77
+
78
+ export function peekPage(url: string): PageModule | undefined {
79
+ const tracked = cache.get(url);
80
+ return tracked?.status === 'fulfilled' ? tracked.value : undefined;
81
+ }
@@ -0,0 +1,41 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { deserializePageTree } from 'fumadocs-core/source/client';
3
+ import type * as PageTree from 'fumadocs-core/page-tree';
4
+ import { getTree, subscribeTree } from 'virtual:seemore/tree';
5
+
6
+ /**
7
+ * The page tree, kept current across content edits.
8
+ *
9
+ * `virtual:seemore/tree` is a self-accepting module, so a create, rename, retitle or delete
10
+ * replaces its value and notifies here — the sidebar re-renders in place, with no reload and
11
+ * no lost scroll position.
12
+ */
13
+ export function usePageTree(): PageTree.Root {
14
+ const serialized = useSyncExternalStore(subscribeTree, getTree, getTree);
15
+ return deserializePageTree(serialized);
16
+ }
17
+
18
+ /**
19
+ * `navigation.prune`: render only the subtree around the current page.
20
+ *
21
+ * Large sites pay for a sidebar that renders every page on every navigation; pruning keeps
22
+ * the active branch and collapses the rest.
23
+ */
24
+ export function pruneTree(root: PageTree.Root, url: string): PageTree.Root {
25
+ return { ...root, children: keep(root.children, url) };
26
+ }
27
+
28
+ function keep(nodes: PageTree.Node[], url: string): PageTree.Node[] {
29
+ return nodes.map((node): PageTree.Node => {
30
+ if (node.type !== 'folder') return node;
31
+ const children = containsUrl(node, url) ? keep(node.children, url) : [];
32
+ return { ...node, children };
33
+ });
34
+ }
35
+
36
+ function containsUrl(node: PageTree.Node, url: string): boolean {
37
+ if (node.type === 'page') return node.url === url;
38
+ if (node.type !== 'folder') return false;
39
+ if (node.index?.url === url) return true;
40
+ return node.children.some((child) => containsUrl(child, url));
41
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Mermaid runs in the browser.
5
+ *
6
+ * Rendering diagrams at build time would mean `rehype-mermaid`, which means Playwright and a
7
+ * downloaded browser as an install dependency of a documentation CLI. The stated consequence
8
+ * is that diagrams are absent from the prerendered HTML; page *text*, which is what the SEO
9
+ * guarantee protects, is not.
10
+ */
11
+ export function Mermaid({ chart }: { chart: string }) {
12
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '');
13
+ const container = useRef<HTMLDivElement>(null);
14
+ const [svg, setSvg] = useState<string>();
15
+ const [error, setError] = useState<string>();
16
+
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+
20
+ void (async () => {
21
+ try {
22
+ const { default: mermaid } = await import('mermaid');
23
+ const dark = document.documentElement.classList.contains('dark');
24
+ mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'default', securityLevel: 'strict' });
25
+ const { svg: rendered } = await mermaid.render(`seemore-mermaid-${id}`, chart);
26
+ if (!cancelled) setSvg(rendered);
27
+ } catch (cause) {
28
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
29
+ }
30
+ })();
31
+
32
+ return () => {
33
+ cancelled = true;
34
+ };
35
+ }, [chart, id]);
36
+
37
+ if (error !== undefined) {
38
+ return (
39
+ <pre className="seemore-mermaid-error" role="note">
40
+ {`Could not render this diagram: ${error}`}
41
+ </pre>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div
47
+ ref={container}
48
+ className="seemore-mermaid"
49
+ // The diagram is generated by mermaid from the page's own source, not from user input.
50
+ dangerouslySetInnerHTML={svg === undefined ? undefined : { __html: svg }}
51
+ >
52
+ {svg === undefined ? <pre className="seemore-mermaid-source">{chart}</pre> : undefined}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export default Mermaid;
@@ -0,0 +1,25 @@
1
+ import type { ComponentProps } from 'react';
2
+
3
+ /**
4
+ * Sibling PDFs render in the browser's own viewer.
5
+ *
6
+ * Every element here is inline-level. Markdown puts an image in a paragraph, and a `<figure>`
7
+ * or any other block element inside a `<p>` makes the HTML parser close the paragraph early —
8
+ * so the prerendered markup and React's tree disagree, and hydration throws. `display: block`
9
+ * on a `<span>` gets the same layout with none of that.
10
+ *
11
+ * `pdfjs-dist` is roughly a megabyte, which is a poor trade for a docs site. The accepted
12
+ * cost is that most mobile browsers degrade `<embed>` to a download link.
13
+ */
14
+ export function Pdf({ src, title, ...props }: ComponentProps<'embed'> & { src: string }) {
15
+ return (
16
+ <span className="seemore-pdf">
17
+ <embed src={src} type="application/pdf" title={title} {...props} />
18
+ <a href={src} download>
19
+ Download {title ?? 'PDF'}
20
+ </a>
21
+ </span>
22
+ );
23
+ }
24
+
25
+ export default Pdf;
@@ -0,0 +1,57 @@
1
+ import type { AnchorHTMLAttributes, ComponentProps } from 'react';
2
+ import { Link } from 'react-router';
3
+ import defaultMdxComponents from 'fumadocs-ui/mdx';
4
+ import { config } from 'virtual:seemore/config';
5
+ import { isExternalHref, stripBase } from '../../shared/base.js';
6
+ import { Mermaid } from './Mermaid.js';
7
+ import { Pdf } from './Pdf.js';
8
+
9
+ /**
10
+ * Internal links go through React Router so navigation stays client-side.
11
+ *
12
+ * remark rewrote content links to *based* hrefs, and React Router re-applies the
13
+ * basename itself, so the base is stripped here to keep it from appearing twice.
14
+ */
15
+ function MdxLink({ href = '', children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
16
+ if (isExternalHref(href)) {
17
+ const external = /^[a-z][a-z0-9+.-]*:|^\/\//i.test(href);
18
+ return (
19
+ <a href={href} {...(external ? { target: '_blank', rel: 'noreferrer noopener' } : {})} {...props}>
20
+ {children}
21
+ </a>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <Link to={stripBase(config.base, href)} viewTransition {...props}>
27
+ {children}
28
+ </Link>
29
+ );
30
+ }
31
+
32
+ function isPdf(src: string | undefined): src is string {
33
+ if (typeof src !== 'string') return false;
34
+ // A small PDF is inlined by the bundler, so the extension is gone and the mime type is
35
+ // the only thing left to go on.
36
+ return /\.pdf(?:[?#].*)?$/i.test(src) || src.startsWith('data:application/pdf');
37
+ }
38
+
39
+ /** Sibling assets: images inline, PDFs in a viewer. */
40
+ function MdxImage({ src, alt, ...props }: ComponentProps<'img'>) {
41
+ const source = typeof src === 'string' ? src : undefined;
42
+ if (isPdf(source)) {
43
+ return <Pdf src={source} title={alt} />;
44
+ }
45
+ const Image = defaultMdxComponents.img;
46
+ return <Image src={src} alt={alt} {...props} />;
47
+ }
48
+
49
+ export const mdxComponents = {
50
+ ...defaultMdxComponents,
51
+ a: MdxLink,
52
+ img: MdxImage,
53
+ // `remark-mdx-mermaid` rewrites ```mermaid fences to <Mermaid chart="…" />, but supplies no
54
+ // component of its own — this is ours.
55
+ Mermaid,
56
+ Pdf,
57
+ };
@@ -0,0 +1,43 @@
1
+ import { Suspense } from 'react';
2
+ import { useLocation, type RouteObject } from 'react-router';
3
+ import { decodePath } from '../shared/base.js';
4
+ import { DocPage, DocsLayout, NotFound } from './layout/DocsLayout.js';
5
+ import { useRouteEntry } from './lib/pages.js';
6
+
7
+ /** The current route URL: React Router has already removed the basename. */
8
+ export function useRouteUrl(): string {
9
+ const { pathname } = useLocation();
10
+ const trimmed = decodePath(pathname).replace(/\/+$/, '');
11
+ return trimmed === '' ? '/' : trimmed;
12
+ }
13
+
14
+ function Page() {
15
+ const entry = useRouteEntry(useRouteUrl());
16
+ return entry === undefined ? <NotFound /> : <DocPage entry={entry} />;
17
+ }
18
+
19
+ /**
20
+ * One catch-all route, matched against `virtual:seemore/routes` at render time.
21
+ *
22
+ * Our URLs are exact strings, so there is no pattern matching for a router to do — and a
23
+ * route table that never changes shape means creating or deleting a page needs no new
24
+ * router, which is what keeps the dev-mode sidebar refresh a re-render rather than a reload.
25
+ *
26
+ * A data router, because fumadocs' React Router integration uses its hooks — but **no route
27
+ * carries a loader**, which is the actual guarantee. With nothing to fetch,
28
+ * there is no code path in which server-side data can fail to prerender.
29
+ */
30
+ export function createRouteObjects(): RouteObject[] {
31
+ return [
32
+ {
33
+ path: '*',
34
+ element: (
35
+ <DocsLayout>
36
+ <Suspense fallback={<div className="seemore-loading" aria-busy="true" />}>
37
+ <Page />
38
+ </Suspense>
39
+ </DocsLayout>
40
+ ),
41
+ },
42
+ ];
43
+ }
@@ -0,0 +1,103 @@
1
+ import { useMemo } from 'react';
2
+ import { useNavigate } from 'react-router';
3
+ import { useDocsSearch } from 'fumadocs-core/search/client';
4
+ import {
5
+ SearchDialog as Dialog,
6
+ SearchDialogClose,
7
+ SearchDialogContent,
8
+ SearchDialogHeader,
9
+ SearchDialogIcon,
10
+ SearchDialogInput,
11
+ SearchDialogList,
12
+ SearchDialogOverlay,
13
+ } from 'fumadocs-ui/components/dialog/search';
14
+ import type { SharedProps } from 'fumadocs-ui/contexts/search';
15
+ import { config } from 'virtual:seemore/config';
16
+ import { stripBase } from '../../shared/base.js';
17
+ import { feature } from '../lib/features.js';
18
+ import { createSearchClient } from './client.js';
19
+
20
+ /**
21
+ * Our own dialog rather than fumadocs' `DefaultSearchDialog`, because the default one talks
22
+ * to a search *route*; seemore has no server, so the client is a static index read in a
23
+ * worker.
24
+ */
25
+ /**
26
+ * Add the highlight query to a result URL.
27
+ *
28
+ * A content match points at a heading, so the URL already has a fragment — and a query
29
+ * appended after one is part of the fragment, not a search param.
30
+ */
31
+ function withHighlight(url: string, query: string): string {
32
+ if (!feature('search.highlight') || query === '') return url;
33
+
34
+ const hashAt = url.indexOf('#');
35
+ const path = hashAt === -1 ? url : url.slice(0, hashAt);
36
+ const hash = hashAt === -1 ? '' : url.slice(hashAt);
37
+ return `${path}?h=${encodeURIComponent(query)}${hash}`;
38
+ }
39
+
40
+ export function SearchDialog(props: SharedProps) {
41
+ const navigate = useNavigate();
42
+
43
+ const client = useMemo(() => createSearchClient(config.search), []);
44
+ const { search, setSearch, query } = useDocsSearch({ client });
45
+ const results = query.data === 'empty' || query.data === undefined ? [] : query.data;
46
+
47
+ // `search.suggest`: complete the last word inline from the best result's title.
48
+ const completion = useMemo(() => {
49
+ if (!feature('search.suggest') || search === '' || results.length === 0) return '';
50
+ const title = results[0]?.content ?? '';
51
+ return title.toLowerCase().startsWith(search.toLowerCase()) ? title.slice(search.length) : '';
52
+ }, [results, search]);
53
+
54
+ const items = results.map((result) => ({ ...result, external: false }));
55
+
56
+ return (
57
+ <Dialog
58
+ {...props}
59
+ search={search}
60
+ onSearchChange={setSearch}
61
+ isLoading={query.isLoading}
62
+ onSelect={(item) => {
63
+ if (item.type === 'action') return;
64
+ props.onOpenChange(false);
65
+ // `search.highlight`: the query travels with the link, so a shared result highlights
66
+ // too — which is why there is no separate `search.share`.
67
+ void navigate(withHighlight(stripBase(config.base, item.url), search), { viewTransition: true });
68
+ }}
69
+ >
70
+ <SearchDialogOverlay />
71
+ <SearchDialogContent>
72
+ <SearchDialogHeader>
73
+ <SearchDialogIcon />
74
+ <SearchDialogInput
75
+ placeholder="Search documentation…"
76
+ onKeyDown={(event) => {
77
+ if (event.key !== 'ArrowRight' || completion === '') return;
78
+ event.preventDefault();
79
+ setSearch(search + completion);
80
+ }}
81
+ />
82
+ <SearchDialogClose />
83
+ </SearchDialogHeader>
84
+ {completion === '' ? undefined : (
85
+ <p className="seemore-search-suggestion" aria-hidden="true">
86
+ {search}
87
+ <span>{completion}</span>
88
+ </p>
89
+ )}
90
+ {query.error === undefined ? (
91
+ <SearchDialogList items={items} />
92
+ ) : (
93
+ // A search box that silently finds nothing is worse than one that says why.
94
+ <p className="seemore-search-error" role="alert">
95
+ {query.error.message}
96
+ </p>
97
+ )}
98
+ </SearchDialogContent>
99
+ </Dialog>
100
+ );
101
+ }
102
+
103
+ export default SearchDialog;