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.
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli/index.js +1635 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/package.json +98 -0
- package/src/app/entry.client.tsx +30 -0
- package/src/app/entry.prerender.tsx +67 -0
- package/src/app/features/anchors.ts +23 -0
- package/src/app/features/highlight.ts +77 -0
- package/src/app/features/prefetch.ts +55 -0
- package/src/app/features/preview.tsx +73 -0
- package/src/app/index.html +12 -0
- package/src/app/layout/Breadcrumb.tsx +29 -0
- package/src/app/layout/DocsLayout.tsx +103 -0
- package/src/app/layout/Footer.tsx +72 -0
- package/src/app/layout/Header.tsx +59 -0
- package/src/app/layout/Provider.tsx +60 -0
- package/src/app/layout/Sidebar.tsx +90 -0
- package/src/app/layout/Toc.tsx +59 -0
- package/src/app/lib/features.ts +7 -0
- package/src/app/lib/pages.ts +81 -0
- package/src/app/lib/tree.ts +41 -0
- package/src/app/mdx/Mermaid.tsx +57 -0
- package/src/app/mdx/Pdf.tsx +25 -0
- package/src/app/mdx/components.tsx +57 -0
- package/src/app/router.tsx +43 -0
- package/src/app/search/SearchDialog.tsx +103 -0
- package/src/app/search/client.ts +143 -0
- package/src/app/search/worker.ts +42 -0
- package/src/app/styles/globals.css +224 -0
- package/src/app/virtual.d.ts +17 -0
- package/src/shared/base.ts +67 -0
- package/src/shared/og.ts +12 -0
- package/src/shared/types.ts +77 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
3
|
+
import { RouterProvider, createBrowserRouter } from 'react-router';
|
|
4
|
+
import { config } from 'virtual:seemore/config';
|
|
5
|
+
import { decodePath, stripBase, toBasename } from '../shared/base.js';
|
|
6
|
+
import { createRouteObjects } from './router.js';
|
|
7
|
+
import { preloadPage } from './lib/pages.js';
|
|
8
|
+
import './styles/globals.css';
|
|
9
|
+
|
|
10
|
+
const container = document.getElementById('root');
|
|
11
|
+
if (container === null) throw new Error('seemore: #root is missing from the page shell.');
|
|
12
|
+
|
|
13
|
+
const router = createBrowserRouter(createRouteObjects(), { basename: toBasename(config.base) });
|
|
14
|
+
|
|
15
|
+
function mount(target: HTMLElement) {
|
|
16
|
+
hydrateRoot(
|
|
17
|
+
target,
|
|
18
|
+
<StrictMode>
|
|
19
|
+
<RouterProvider router={router} />
|
|
20
|
+
</StrictMode>,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Hydrating against prerendered HTML needs the current page's module in hand, or React would
|
|
25
|
+
// hydrate a Suspense fallback over real markup.
|
|
26
|
+
const current = stripBase(config.base, decodePath(window.location.pathname)).replace(/\/$/, '') || '/';
|
|
27
|
+
void preloadPage(current).then(
|
|
28
|
+
() => mount(container),
|
|
29
|
+
() => mount(container),
|
|
30
|
+
);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { RouterProvider, createMemoryRouter } from 'react-router';
|
|
4
|
+
import { config } from 'virtual:seemore/config';
|
|
5
|
+
import { toBasename, withBase } from '../shared/base.js';
|
|
6
|
+
import { ogImagePath } from '../shared/og.js';
|
|
7
|
+
import { createRouteObjects } from './router.js';
|
|
8
|
+
import { findRoute, preloadPage, routeEntries } from './lib/pages.js';
|
|
9
|
+
|
|
10
|
+
export interface RenderResult {
|
|
11
|
+
html: string;
|
|
12
|
+
head: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The prerender driver.
|
|
17
|
+
*
|
|
18
|
+
* The page's module is loaded first, so `use()` resolves synchronously and `renderToString`
|
|
19
|
+
* emits the complete article rather than a Suspense fallback. No route has a loader, so the
|
|
20
|
+
* memory router is initialised the moment it is created.
|
|
21
|
+
*/
|
|
22
|
+
export async function render(url: string): Promise<RenderResult> {
|
|
23
|
+
await preloadPage(url);
|
|
24
|
+
|
|
25
|
+
const router = createMemoryRouter(createRouteObjects(), {
|
|
26
|
+
basename: toBasename(config.base),
|
|
27
|
+
initialEntries: [withBase(config.base, url)],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const html = renderToString(
|
|
31
|
+
<StrictMode>
|
|
32
|
+
<RouterProvider router={router} />
|
|
33
|
+
</StrictMode>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return { html, head: head(url) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listRoutes(): string[] {
|
|
40
|
+
return routeEntries().map((entry) => entry.url);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function head(url: string): string {
|
|
44
|
+
const entry = findRoute(url);
|
|
45
|
+
const title = entry === undefined || url === '/' ? config.title : `${entry.title} · ${config.title}`;
|
|
46
|
+
const description = entry?.description ?? config.description;
|
|
47
|
+
|
|
48
|
+
const tags = [`<title>${escapeHtml(title)}</title>`];
|
|
49
|
+
if (description != null) tags.push(`<meta name="description" content="${escapeHtml(description)}" />`);
|
|
50
|
+
if (config.favicon !== undefined) tags.push(`<link rel="icon" href="${escapeHtml(config.favicon)}" />`);
|
|
51
|
+
|
|
52
|
+
tags.push(`<meta property="og:title" content="${escapeHtml(title)}" />`);
|
|
53
|
+
if (description != null) tags.push(`<meta property="og:description" content="${escapeHtml(description)}" />`);
|
|
54
|
+
|
|
55
|
+
if (config.features['social.cards']) {
|
|
56
|
+
const card = escapeHtml(withBase(config.base, ogImagePath(url)));
|
|
57
|
+
tags.push(`<meta property="og:image" content="${card}" />`);
|
|
58
|
+
tags.push(`<meta name="twitter:card" content="summary_large_image" />`);
|
|
59
|
+
tags.push(`<meta name="twitter:image" content="${card}" />`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return tags.join('\n ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeHtml(value: string): string {
|
|
66
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client-side navigation to `#hash` does not scroll on its own, and the target element only
|
|
6
|
+
* exists once the page's MDX module has rendered — so scrolling waits a frame.
|
|
7
|
+
*/
|
|
8
|
+
export function useHashScroll(): void {
|
|
9
|
+
const location = useLocation();
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (location.hash === '') {
|
|
13
|
+
if (location.key !== 'default') window.scrollTo({ top: 0 });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const id = decodeURIComponent(location.hash.slice(1));
|
|
18
|
+
const frame = requestAnimationFrame(() => {
|
|
19
|
+
document.getElementById(id)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
20
|
+
});
|
|
21
|
+
return () => cancelAnimationFrame(frame);
|
|
22
|
+
}, [location.key, location.hash]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
3
|
+
import { feature } from '../lib/features.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `search.highlight`: mark every occurrence of the query on the page you landed on.
|
|
7
|
+
*
|
|
8
|
+
* The query rides in `?h=`, so a link copied from the address bar highlights for the next
|
|
9
|
+
* reader too — which is why there is no separate `search.share` flag.
|
|
10
|
+
*/
|
|
11
|
+
export function useSearchHighlight(): void {
|
|
12
|
+
const location = useLocation();
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!feature('search.highlight')) return;
|
|
16
|
+
const query = new URLSearchParams(location.search).get('h');
|
|
17
|
+
if (query === null || query.trim() === '') return;
|
|
18
|
+
|
|
19
|
+
const article = document.querySelector('article');
|
|
20
|
+
if (article === null) return;
|
|
21
|
+
|
|
22
|
+
const ranges = findRanges(article, query.trim());
|
|
23
|
+
if (ranges.length === 0) return;
|
|
24
|
+
|
|
25
|
+
// CSS Custom Highlight API where available: no DOM mutation, so React never fights it.
|
|
26
|
+
const highlightApi = (CSS as unknown as { highlights?: Map<string, unknown> }).highlights;
|
|
27
|
+
const HighlightCtor = (globalThis as unknown as { Highlight?: new (...r: Range[]) => unknown }).Highlight;
|
|
28
|
+
|
|
29
|
+
if (highlightApi !== undefined && HighlightCtor !== undefined) {
|
|
30
|
+
ensureHighlightStyle();
|
|
31
|
+
highlightApi.set('seemore-search', new HighlightCtor(...ranges));
|
|
32
|
+
ranges[0]?.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
33
|
+
return () => {
|
|
34
|
+
highlightApi.delete('seemore-search');
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ranges[0]?.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
39
|
+
return undefined;
|
|
40
|
+
}, [location.key, location.search]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `::highlight()` rule is installed here rather than in the stylesheet: CSS optimisers
|
|
45
|
+
* do not yet recognise the selector and warn about it on every build, and the rule is only
|
|
46
|
+
* meaningful where the Custom Highlight API exists anyway.
|
|
47
|
+
*/
|
|
48
|
+
function ensureHighlightStyle(): void {
|
|
49
|
+
const id = 'seemore-highlight-style';
|
|
50
|
+
if (document.getElementById(id) !== null) return;
|
|
51
|
+
|
|
52
|
+
const style = document.createElement('style');
|
|
53
|
+
style.id = id;
|
|
54
|
+
style.textContent =
|
|
55
|
+
'::highlight(seemore-search){background-color:color-mix(in oklab,var(--color-fd-primary) 30%,transparent)}';
|
|
56
|
+
document.head.append(style);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findRanges(root: Element, query: string): Range[] {
|
|
60
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
61
|
+
const needle = query.toLowerCase();
|
|
62
|
+
const ranges: Range[] = [];
|
|
63
|
+
|
|
64
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
65
|
+
const text = node.nodeValue?.toLowerCase() ?? '';
|
|
66
|
+
let from = text.indexOf(needle);
|
|
67
|
+
while (from !== -1) {
|
|
68
|
+
const range = document.createRange();
|
|
69
|
+
range.setStart(node, from);
|
|
70
|
+
range.setEnd(node, from + needle.length);
|
|
71
|
+
ranges.push(range);
|
|
72
|
+
from = text.indexOf(needle, from + needle.length);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return ranges;
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { config } from 'virtual:seemore/config';
|
|
3
|
+
import { decodePath, stripBase } from '../../shared/base.js';
|
|
4
|
+
import { findRoute, loadPage } from '../lib/pages.js';
|
|
5
|
+
import { feature } from '../lib/features.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `navigation.instant.prefetch`: load the target page's chunk on hover.
|
|
9
|
+
*
|
|
10
|
+
* React Router's `<Link prefetch>` is framework-mode only and does nothing in library mode,
|
|
11
|
+
* so the prefetch is ours. It reads the same import map the router and prerender read, which
|
|
12
|
+
* is also what makes instant previews possible.
|
|
13
|
+
*/
|
|
14
|
+
export function usePrefetch(): void {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!feature('navigation.instant.prefetch')) return;
|
|
17
|
+
|
|
18
|
+
const onPointerOver = (event: PointerEvent) => {
|
|
19
|
+
const url = routeUrlFromEvent(event);
|
|
20
|
+
if (url === undefined) return;
|
|
21
|
+
const entry = findRoute(url);
|
|
22
|
+
if (entry !== undefined) void loadPage(entry);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
document.addEventListener('pointerover', onPointerOver, { passive: true });
|
|
26
|
+
return () => document.removeEventListener('pointerover', onPointerOver);
|
|
27
|
+
}, []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The route URL a pointer event points at, or `undefined` if it points at nothing of ours. */
|
|
31
|
+
export function routeUrlFromEvent(event: Event): string | undefined {
|
|
32
|
+
const target = event.target;
|
|
33
|
+
if (!(target instanceof Element)) return undefined;
|
|
34
|
+
const anchor = target.closest('a');
|
|
35
|
+
if (anchor === null) return undefined;
|
|
36
|
+
return routeUrlFromAnchor(anchor);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function routeUrlFromAnchor(anchor: HTMLAnchorElement): string | undefined {
|
|
40
|
+
const href = anchor.getAttribute('href');
|
|
41
|
+
if (href === null || href === '' || href.startsWith('#')) return undefined;
|
|
42
|
+
if (anchor.target === '_blank') return undefined;
|
|
43
|
+
|
|
44
|
+
let pathname: string;
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(anchor.href, window.location.href);
|
|
47
|
+
if (url.origin !== window.location.origin) return undefined;
|
|
48
|
+
pathname = decodePath(url.pathname);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const route = stripBase(config.base, pathname).replace(/\/$/, '');
|
|
54
|
+
return route === '' ? '/' : route;
|
|
55
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { config } from 'virtual:seemore/config';
|
|
3
|
+
import { findRoute, loadPage, peekPage } from '../lib/pages.js';
|
|
4
|
+
import { feature } from '../lib/features.js';
|
|
5
|
+
import { mdxComponents } from '../mdx/components.js';
|
|
6
|
+
import { routeUrlFromEvent } from './prefetch.js';
|
|
7
|
+
|
|
8
|
+
interface PreviewState {
|
|
9
|
+
url: string;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `navigation.instant.preview`: a hover popover rendering the target page inline.
|
|
16
|
+
*
|
|
17
|
+
* This is the reason the prefetch module exists independently of any router: it needs to
|
|
18
|
+
* load *and render* the target module, which no router's preload gives you.
|
|
19
|
+
*/
|
|
20
|
+
export function PagePreview() {
|
|
21
|
+
const [state, setState] = useState<PreviewState>();
|
|
22
|
+
const enabled = feature('navigation.instant.preview');
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!enabled) return;
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
27
|
+
|
|
28
|
+
const onPointerOver = (event: PointerEvent) => {
|
|
29
|
+
const url = routeUrlFromEvent(event);
|
|
30
|
+
const entry = url === undefined ? undefined : findRoute(url);
|
|
31
|
+
if (entry === undefined) return;
|
|
32
|
+
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
void loadPage(entry).then(() => {
|
|
36
|
+
setState({ url: entry.url, x: event.clientX, y: event.clientY });
|
|
37
|
+
});
|
|
38
|
+
}, 350);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const onPointerOut = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
setState(undefined);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
document.addEventListener('pointerover', onPointerOver, { passive: true });
|
|
47
|
+
document.addEventListener('pointerout', onPointerOut, { passive: true });
|
|
48
|
+
return () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
document.removeEventListener('pointerover', onPointerOver);
|
|
51
|
+
document.removeEventListener('pointerout', onPointerOut);
|
|
52
|
+
};
|
|
53
|
+
}, [enabled]);
|
|
54
|
+
|
|
55
|
+
if (!enabled || state === undefined) return null;
|
|
56
|
+
|
|
57
|
+
const loaded = peekPage(state.url);
|
|
58
|
+
if (loaded === undefined) return null;
|
|
59
|
+
const Content = loaded.default;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className="seemore-preview"
|
|
64
|
+
role="tooltip"
|
|
65
|
+
style={{ left: Math.min(state.x + 16, window.innerWidth - 420), top: state.y + 16 }}
|
|
66
|
+
>
|
|
67
|
+
<p className="seemore-preview-title">{findRoute(state.url)?.title ?? config.title}</p>
|
|
68
|
+
<div className="seemore-preview-body" aria-hidden="true">
|
|
69
|
+
<Content components={mdxComponents} />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<!--seemore-head-->
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"><!--seemore-app--></div>
|
|
10
|
+
<script type="module" src="./entry.client.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Fragment } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { useTreePath } from 'fumadocs-ui/contexts/tree';
|
|
4
|
+
|
|
5
|
+
/** `navigation.path`. */
|
|
6
|
+
export function Breadcrumb() {
|
|
7
|
+
const path = useTreePath();
|
|
8
|
+
if (path.length === 0) return null;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<nav className="seemore-breadcrumb" aria-label="Breadcrumb">
|
|
12
|
+
{path.map((node, index) => {
|
|
13
|
+
const url = node.type === 'page' ? node.url : node.type === 'folder' ? node.index?.url : undefined;
|
|
14
|
+
return (
|
|
15
|
+
<Fragment key={`${String(node.name)}-${index}`}>
|
|
16
|
+
{index > 0 ? <span aria-hidden="true">/</span> : undefined}
|
|
17
|
+
{url === undefined ? (
|
|
18
|
+
<span>{node.name}</span>
|
|
19
|
+
) : (
|
|
20
|
+
<Link to={url} viewTransition>
|
|
21
|
+
{node.name}
|
|
22
|
+
</Link>
|
|
23
|
+
)}
|
|
24
|
+
</Fragment>
|
|
25
|
+
);
|
|
26
|
+
})}
|
|
27
|
+
</nav>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { use } from 'react';
|
|
2
|
+
import { TreeContextProvider } from 'fumadocs-ui/contexts/tree';
|
|
3
|
+
import { SidebarProvider } from 'fumadocs-ui/components/sidebar/base';
|
|
4
|
+
import { Pencil } from 'lucide-react';
|
|
5
|
+
import { config } from 'virtual:seemore/config';
|
|
6
|
+
import type { PageModule, RouteEntry } from '../../shared/types.js';
|
|
7
|
+
import { loadPage } from '../lib/pages.js';
|
|
8
|
+
import { useRouteUrl } from '../router.js';
|
|
9
|
+
import { feature } from '../lib/features.js';
|
|
10
|
+
import { pruneTree, usePageTree } from '../lib/tree.js';
|
|
11
|
+
import { mdxComponents } from '../mdx/components.js';
|
|
12
|
+
import { usePrefetch } from '../features/prefetch.js';
|
|
13
|
+
import { PagePreview } from '../features/preview.js';
|
|
14
|
+
import { useSearchHighlight } from '../features/highlight.js';
|
|
15
|
+
import { useHashScroll } from '../features/anchors.js';
|
|
16
|
+
import { SeemoreProvider } from './Provider.js';
|
|
17
|
+
import { Header } from './Header.js';
|
|
18
|
+
import { Sidebar } from './Sidebar.js';
|
|
19
|
+
import { Breadcrumb } from './Breadcrumb.js';
|
|
20
|
+
import { BackToTop, PageFooter, SiteFooter } from './Footer.js';
|
|
21
|
+
import { IntegratedToc, Toc, TocProvider } from './Toc.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The shell seemore owns. fumadocs-ui supplies the primitives — sidebar,
|
|
25
|
+
* TOC, search dialog, MDX components — and one layout, tuned by feature flags, arranges them.
|
|
26
|
+
*/
|
|
27
|
+
export function DocsLayout({ children }: { children: React.ReactNode }) {
|
|
28
|
+
const tree = usePageTree();
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<SeemoreProvider>
|
|
32
|
+
<TreeContextProvider tree={tree}>
|
|
33
|
+
<SidebarProvider>{children}</SidebarProvider>
|
|
34
|
+
</TreeContextProvider>
|
|
35
|
+
</SeemoreProvider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
40
|
+
const full = usePageTree();
|
|
41
|
+
const url = useRouteUrl();
|
|
42
|
+
const tree = feature('navigation.prune') ? pruneTree(full, url) : full;
|
|
43
|
+
|
|
44
|
+
// `use()` on the cached module promise: already-loaded pages render synchronously, which
|
|
45
|
+
// is what makes `renderToString` emit a complete page.
|
|
46
|
+
const page = use(loadPage(entry) as Promise<PageModule>);
|
|
47
|
+
const Content = page.default;
|
|
48
|
+
|
|
49
|
+
usePrefetch();
|
|
50
|
+
useSearchHighlight();
|
|
51
|
+
useHashScroll();
|
|
52
|
+
|
|
53
|
+
const integrated = feature('toc.integrate');
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TocProvider toc={page.toc ?? []}>
|
|
57
|
+
<div className="seemore-shell">
|
|
58
|
+
<Header />
|
|
59
|
+
<div className="seemore-body">
|
|
60
|
+
<Sidebar>{integrated ? <IntegratedToc /> : undefined}</Sidebar>
|
|
61
|
+
|
|
62
|
+
<main className="seemore-main">
|
|
63
|
+
{feature('navigation.path') ? <Breadcrumb /> : undefined}
|
|
64
|
+
<article className="seemore-article prose">
|
|
65
|
+
<Content components={mdxComponents} />
|
|
66
|
+
</article>
|
|
67
|
+
|
|
68
|
+
{config.editLink !== undefined && feature('content.action.edit') ? (
|
|
69
|
+
<a className="seemore-edit-link" href={joinUrl(config.editLink.base, entry.file)}>
|
|
70
|
+
<Pencil aria-hidden="true" />
|
|
71
|
+
{config.editLink.text}
|
|
72
|
+
</a>
|
|
73
|
+
) : undefined}
|
|
74
|
+
|
|
75
|
+
{feature('navigation.footer') ? <PageFooter tree={tree} url={url} /> : undefined}
|
|
76
|
+
<SiteFooter />
|
|
77
|
+
</main>
|
|
78
|
+
|
|
79
|
+
{integrated ? undefined : <Toc />}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{feature('navigation.top') ? <BackToTop /> : undefined}
|
|
83
|
+
<PagePreview />
|
|
84
|
+
</div>
|
|
85
|
+
</TocProvider>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function NotFound() {
|
|
90
|
+
return (
|
|
91
|
+
<div className="seemore-shell">
|
|
92
|
+
<Header />
|
|
93
|
+
<main className="seemore-main">
|
|
94
|
+
<h1>Page not found</h1>
|
|
95
|
+
<p>There is no page at this address.</p>
|
|
96
|
+
</main>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function joinUrl(base: string, path: string): string {
|
|
102
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { ArrowLeft, ArrowRight, ArrowUp } from 'lucide-react';
|
|
4
|
+
import { findNeighbour } from 'fumadocs-core/page-tree';
|
|
5
|
+
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
6
|
+
import { config } from 'virtual:seemore/config';
|
|
7
|
+
|
|
8
|
+
/** `navigation.footer`: previous / next links. */
|
|
9
|
+
export function PageFooter({ tree, url }: { tree: PageTree.Root; url: string }) {
|
|
10
|
+
const { previous, next } = findNeighbour(tree, url);
|
|
11
|
+
if (previous === undefined && next === undefined) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<nav className="seemore-page-footer" aria-label="Previous and next page">
|
|
15
|
+
{previous === undefined ? (
|
|
16
|
+
<span />
|
|
17
|
+
) : (
|
|
18
|
+
<Link to={previous.url} viewTransition className="seemore-prev">
|
|
19
|
+
<ArrowLeft aria-hidden="true" />
|
|
20
|
+
<span>{previous.name}</span>
|
|
21
|
+
</Link>
|
|
22
|
+
)}
|
|
23
|
+
{next === undefined ? (
|
|
24
|
+
<span />
|
|
25
|
+
) : (
|
|
26
|
+
<Link to={next.url} viewTransition className="seemore-next">
|
|
27
|
+
<span>{next.name}</span>
|
|
28
|
+
<ArrowRight aria-hidden="true" />
|
|
29
|
+
</Link>
|
|
30
|
+
)}
|
|
31
|
+
</nav>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SiteFooter() {
|
|
36
|
+
if (config.footer === undefined) return null;
|
|
37
|
+
return (
|
|
38
|
+
<footer className="seemore-site-footer">
|
|
39
|
+
{config.footer.text === undefined ? undefined : <p>{config.footer.text}</p>}
|
|
40
|
+
{(config.footer.links ?? []).map((link) => (
|
|
41
|
+
<a key={link.link} href={link.link}>
|
|
42
|
+
{link.text}
|
|
43
|
+
</a>
|
|
44
|
+
))}
|
|
45
|
+
</footer>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `navigation.top`. Hidden until the page has actually been scrolled. */
|
|
50
|
+
export function BackToTop() {
|
|
51
|
+
const [visible, setVisible] = useState(false);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const onScroll = () => setVisible(window.scrollY > 400);
|
|
55
|
+
onScroll();
|
|
56
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
57
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
if (!visible) return null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className="seemore-back-to-top"
|
|
66
|
+
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
67
|
+
>
|
|
68
|
+
<ArrowUp aria-hidden="true" />
|
|
69
|
+
<span>Back to top</span>
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router';
|
|
2
|
+
import { Moon, PanelLeft, Search, Sun } from 'lucide-react';
|
|
3
|
+
import { useSearchContext } from 'fumadocs-ui/contexts/search';
|
|
4
|
+
import { SidebarTrigger } from 'fumadocs-ui/components/sidebar/base';
|
|
5
|
+
import { useTheme } from 'fumadocs-ui/provider/base';
|
|
6
|
+
import { config } from 'virtual:seemore/config';
|
|
7
|
+
|
|
8
|
+
export function Header() {
|
|
9
|
+
const search = useSearchContext();
|
|
10
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
11
|
+
const location = useLocation();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<header className="seemore-header">
|
|
15
|
+
<SidebarTrigger className="seemore-sidebar-trigger" aria-label="Toggle navigation">
|
|
16
|
+
<PanelLeft />
|
|
17
|
+
</SidebarTrigger>
|
|
18
|
+
|
|
19
|
+
<Link to="/" className="seemore-brand" viewTransition>
|
|
20
|
+
{config.title}
|
|
21
|
+
</Link>
|
|
22
|
+
|
|
23
|
+
<nav className="seemore-nav" aria-label="Site">
|
|
24
|
+
{(config.nav ?? []).map((item) =>
|
|
25
|
+
item.link === undefined ? (
|
|
26
|
+
<span key={item.text}>{item.text}</span>
|
|
27
|
+
) : (
|
|
28
|
+
<Link
|
|
29
|
+
key={item.text}
|
|
30
|
+
to={item.link}
|
|
31
|
+
viewTransition
|
|
32
|
+
aria-current={location.pathname === item.link ? 'page' : undefined}
|
|
33
|
+
>
|
|
34
|
+
{item.text}
|
|
35
|
+
</Link>
|
|
36
|
+
),
|
|
37
|
+
)}
|
|
38
|
+
</nav>
|
|
39
|
+
|
|
40
|
+
{search.enabled ? (
|
|
41
|
+
<button type="button" className="seemore-search-trigger" onClick={() => search.setOpenSearch(true)}>
|
|
42
|
+
<Search aria-hidden="true" />
|
|
43
|
+
<span>Search</span>
|
|
44
|
+
<kbd>{'⌘K'}</kbd>
|
|
45
|
+
</button>
|
|
46
|
+
) : undefined}
|
|
47
|
+
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
className="seemore-theme-toggle"
|
|
51
|
+
aria-label="Toggle dark mode"
|
|
52
|
+
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
|
53
|
+
>
|
|
54
|
+
<Sun className="seemore-icon-light" aria-hidden="true" />
|
|
55
|
+
<Moon className="seemore-icon-dark" aria-hidden="true" />
|
|
56
|
+
</button>
|
|
57
|
+
</header>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo, type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
import { Link as RouterLink, useLocation, useNavigate, useParams, useRevalidator } from 'react-router';
|
|
3
|
+
import { FrameworkProvider } from 'fumadocs-core/framework';
|
|
4
|
+
import { RootProvider } from 'fumadocs-ui/provider/base';
|
|
5
|
+
import { decodePath } from '../../shared/base.js';
|
|
6
|
+
import { SearchDialog } from '../search/SearchDialog.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* fumadocs' React Router bindings, with one change.
|
|
10
|
+
*
|
|
11
|
+
* Its `usePathname` returns `useLocation().pathname` verbatim, which the browser reports
|
|
12
|
+
* percent-encoded. Every active-state comparison in fumadocs — the highlighted sidebar link,
|
|
13
|
+
* whether a folder starts open — then measures `/gu%C3%ADa/…` against a page tree holding
|
|
14
|
+
* `/guía/…` and finds no match. Prerendering runs against a memory router, where the
|
|
15
|
+
* pathname is *not* encoded, so the two disagree and hydration fails on any page with a
|
|
16
|
+
* non-ASCII route. Decoding here makes both sides read the same URL.
|
|
17
|
+
*/
|
|
18
|
+
function usePathname(): string {
|
|
19
|
+
return decodePath(useLocation().pathname);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function useRouter() {
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const revalidator = useRevalidator();
|
|
25
|
+
|
|
26
|
+
return useMemo(
|
|
27
|
+
() => ({
|
|
28
|
+
push(url: string) {
|
|
29
|
+
void navigate(url);
|
|
30
|
+
},
|
|
31
|
+
refresh() {
|
|
32
|
+
void revalidator.revalidate();
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
[navigate, revalidator],
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** fumadocs' own Link binding, plus the view transition that gives navigation the SPA feel. */
|
|
40
|
+
function Link({ href, prefetch: _prefetch, ...props }: ComponentProps<'a'> & { prefetch?: boolean }) {
|
|
41
|
+
return <RouterLink to={href ?? ''} viewTransition {...props} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function SeemoreProvider({ children }: { children: ReactNode }) {
|
|
45
|
+
return (
|
|
46
|
+
<FrameworkProvider
|
|
47
|
+
usePathname={usePathname}
|
|
48
|
+
useParams={() => useParams() as Record<string, string | string[]>}
|
|
49
|
+
useRouter={useRouter}
|
|
50
|
+
Link={Link}
|
|
51
|
+
>
|
|
52
|
+
<RootProvider
|
|
53
|
+
search={{ enabled: true, SearchDialog }}
|
|
54
|
+
theme={{ attribute: 'class', defaultTheme: 'system', enableSystem: true }}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</RootProvider>
|
|
58
|
+
</FrameworkProvider>
|
|
59
|
+
);
|
|
60
|
+
}
|