seemore 1.1.3 → 1.1.5
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/README.md +72 -22
- package/dist/cli/index.js +9 -4
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/entry.prerender.tsx +72 -4
- package/src/app/layout/DocsLayout.tsx +33 -6
- package/src/app/lib/pages.ts +78 -8
- package/src/app/router.tsx +41 -2
- package/src/shared/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import { StrictMode, type ReactNode } from 'react';
|
|
3
|
+
import { renderToPipeableStream } from 'react-dom/server';
|
|
3
4
|
import { RouterProvider, createMemoryRouter } from 'react-router';
|
|
4
5
|
import { config } from 'virtual:seemore/config';
|
|
5
6
|
import { toBasename, withBase } from '../shared/base.js';
|
|
@@ -15,7 +16,7 @@ export interface RenderResult {
|
|
|
15
16
|
/**
|
|
16
17
|
* The prerender driver.
|
|
17
18
|
*
|
|
18
|
-
* The page's module is loaded first, so `use()` resolves synchronously and
|
|
19
|
+
* The page's module is loaded first, so `use()` resolves synchronously and the renderer
|
|
19
20
|
* emits the complete article rather than a Suspense fallback. No route has a loader, so the
|
|
20
21
|
* memory router is initialised the moment it is created.
|
|
21
22
|
*/
|
|
@@ -27,15 +28,82 @@ export async function render(url: string): Promise<RenderResult> {
|
|
|
27
28
|
initialEntries: [withBase(config.base, url)],
|
|
28
29
|
});
|
|
29
30
|
|
|
30
|
-
const html =
|
|
31
|
+
const { html, failures } = await renderToHtml(
|
|
31
32
|
<StrictMode>
|
|
32
33
|
<RouterProvider router={router} />
|
|
33
34
|
</StrictMode>,
|
|
34
35
|
);
|
|
35
36
|
|
|
37
|
+
// React hands a render error to the nearest Suspense boundary and carries on. Without this
|
|
38
|
+
// the page would be written out as the loading fallback — an empty shell — and the build
|
|
39
|
+
// would report it as a success.
|
|
40
|
+
if (failures.length > 0) throw prerenderError(url, failures[0]);
|
|
41
|
+
|
|
36
42
|
return { html, head: head(url) };
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The whole tree as one string, plus anything that threw while rendering it.
|
|
47
|
+
*
|
|
48
|
+
* `renderToString` would be shorter, but it swallows render errors: its own `onError` is
|
|
49
|
+
* internal and a failed subtree is silently replaced by its Suspense fallback. The streaming
|
|
50
|
+
* renderer reports them, and piping only once `onAllReady` has fired keeps the output the
|
|
51
|
+
* same complete markup — no fallbacks, no streaming scripts.
|
|
52
|
+
*/
|
|
53
|
+
function renderToHtml(element: ReactNode): Promise<{ html: string; failures: unknown[] }> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const failures: unknown[] = [];
|
|
56
|
+
const chunks: Buffer[] = [];
|
|
57
|
+
|
|
58
|
+
const sink = new Writable({
|
|
59
|
+
write(chunk: Buffer, _encoding, done) {
|
|
60
|
+
chunks.push(Buffer.from(chunk));
|
|
61
|
+
done();
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
sink.on('finish', () => {
|
|
65
|
+
resolve({ html: Buffer.concat(chunks).toString('utf8'), failures });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const stream = renderToPipeableStream(element, {
|
|
69
|
+
onError(error: unknown) {
|
|
70
|
+
failures.push(error);
|
|
71
|
+
},
|
|
72
|
+
onAllReady() {
|
|
73
|
+
stream.pipe(sink);
|
|
74
|
+
},
|
|
75
|
+
// Nothing was rendered at all: there is no markup to hand back, and `failures` already
|
|
76
|
+
// holds the reason.
|
|
77
|
+
onShellError() {
|
|
78
|
+
resolve({ html: '', failures });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Components an MDX file can use without importing anything. */
|
|
85
|
+
const PROVIDED_COMPONENTS = 'Callout, Card, Cards, CodeBlockTabs, Mermaid, D2 and Pdf';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A page that threw, reported the way the rest of the build reports problems: the file it
|
|
89
|
+
* came from, what went wrong, and — for the common case of an MDX file reaching for a
|
|
90
|
+
* component that is not there — the fix.
|
|
91
|
+
*/
|
|
92
|
+
function prerenderError(url: string, cause: unknown): Error {
|
|
93
|
+
const file = findRoute(url)?.file;
|
|
94
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
95
|
+
const undefinedComponent = /Expected component `(.+?)` to be defined/.exec(message);
|
|
96
|
+
|
|
97
|
+
const hint =
|
|
98
|
+
undefinedComponent === null
|
|
99
|
+
? ''
|
|
100
|
+
: `\n\n \`<${undefinedComponent[1]}>\` is not one of the components seemore provides ` +
|
|
101
|
+
`(${PROVIDED_COMPONENTS}), and a Markdown file has no imports to add one with. ` +
|
|
102
|
+
`Remove it, or write the markup by hand.`;
|
|
103
|
+
|
|
104
|
+
return new Error(`${file ?? url} failed to render.\n\n ${message}${hint}`, { cause });
|
|
105
|
+
}
|
|
106
|
+
|
|
39
107
|
export function listRoutes(): string[] {
|
|
40
108
|
return routeEntries().map((entry) => entry.url);
|
|
41
109
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { use } from 'react';
|
|
2
1
|
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
3
2
|
import { TreeContextProvider } from 'fumadocs-ui/contexts/tree';
|
|
4
3
|
import { SidebarProvider } from 'fumadocs-ui/components/sidebar/base';
|
|
5
4
|
import { ArrowRight, Pencil } from 'lucide-react';
|
|
6
5
|
import { config } from 'virtual:seemore/config';
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
6
|
+
import type { RouteEntry } from '../../shared/types.js';
|
|
7
|
+
import { usePageModule } from '../lib/pages.js';
|
|
9
8
|
import { useRouteUrl } from '../router.js';
|
|
10
9
|
import { feature } from '../lib/features.js';
|
|
11
10
|
import { pruneTree, usePageTree } from '../lib/tree.js';
|
|
@@ -43,9 +42,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
43
42
|
const url = useRouteUrl();
|
|
44
43
|
const tree = feature('navigation.prune') ? pruneTree(full, url) : full;
|
|
45
44
|
|
|
46
|
-
// `use()` on the cached module promise: already-loaded pages render
|
|
47
|
-
// is what makes `renderToString` emit a complete page.
|
|
48
|
-
const page =
|
|
45
|
+
// `use()` on the cached module promise, inside the hook: already-loaded pages render
|
|
46
|
+
// synchronously, which is what makes `renderToString` emit a complete page.
|
|
47
|
+
const page = usePageModule(entry);
|
|
49
48
|
const Content = page.default;
|
|
50
49
|
|
|
51
50
|
usePrefetch();
|
|
@@ -109,6 +108,34 @@ export function NotFound() {
|
|
|
109
108
|
);
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A page whose own markup threw — an `.mdx` file reaching for a component seemore does not
|
|
113
|
+
* provide, most often. `seemore build` refuses to write such a page at all; here, in the dev
|
|
114
|
+
* server and on client-side navigation, the reason replaces the article, because React
|
|
115
|
+
* unmounts the whole app when nothing catches the error and a blank screen says nothing.
|
|
116
|
+
*/
|
|
117
|
+
export function PageError({ message }: { message: string }) {
|
|
118
|
+
return (
|
|
119
|
+
<div className="seemore-shell">
|
|
120
|
+
<Header />
|
|
121
|
+
<div className="seemore-body">
|
|
122
|
+
<Sidebar />
|
|
123
|
+
|
|
124
|
+
<main className="seemore-main">
|
|
125
|
+
<article className="seemore-article prose">
|
|
126
|
+
<h1>This page failed to render</h1>
|
|
127
|
+
<pre>
|
|
128
|
+
<code>{message}</code>
|
|
129
|
+
</pre>
|
|
130
|
+
</article>
|
|
131
|
+
|
|
132
|
+
<SiteFooter />
|
|
133
|
+
</main>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
/**
|
|
113
140
|
* The generated index: when no `index.md` or root `README.md` claims `/`, the home address
|
|
114
141
|
* lists every page instead of apologising.
|
package/src/app/lib/pages.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useSyncExternalStore } from 'react';
|
|
1
|
+
import { use, useSyncExternalStore } from 'react';
|
|
2
2
|
import { getRoutes, subscribeRoutes } from 'virtual:seemore/routes';
|
|
3
3
|
import type { PageModule, RouteEntry } from '../../shared/types.js';
|
|
4
4
|
|
|
@@ -8,26 +8,48 @@ import type { PageModule, RouteEntry } from '../../shared/types.js';
|
|
|
8
8
|
* The promise is annotated with `status`/`value`, the convention React's `use()` reads, so a
|
|
9
9
|
* module that is already loaded renders synchronously — which is what lets `renderToString`
|
|
10
10
|
* produce a complete page with no Suspense fallback in the output.
|
|
11
|
+
*
|
|
12
|
+
* Entries are keyed by URL and stamped with the route's content `version`. A URL can outlive
|
|
13
|
+
* its module: in dev, a body edit keeps the address and replaces the file behind it, and the
|
|
14
|
+
* cached promise would be the last thing still holding the old component. Fast Refresh does
|
|
15
|
+
* not step in — MDX emits a named `toc` export beside the default one, so the React plugin
|
|
16
|
+
* declines the module and invalidates it instead, and that invalidation is absorbed by the
|
|
17
|
+
* route store's own `accept()`. The version is how the cache notices on its own.
|
|
11
18
|
*/
|
|
12
19
|
type Tracked = Promise<PageModule> & {
|
|
13
20
|
status?: 'pending' | 'fulfilled' | 'rejected';
|
|
14
21
|
value?: PageModule;
|
|
15
22
|
reason?: unknown;
|
|
23
|
+
version: string;
|
|
24
|
+
/** A replacement already loading for a newer version, so an edit is fetched once. */
|
|
25
|
+
next?: Tracked;
|
|
16
26
|
};
|
|
17
27
|
|
|
18
28
|
const cache = new Map<string, Tracked>();
|
|
19
29
|
|
|
20
30
|
let index = buildIndex();
|
|
21
31
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
32
|
+
// Bumped whenever a cached module is swapped for a newer version, so a page that is on
|
|
33
|
+
// screen re-renders onto the new one. Route changes have their own store; this one is only
|
|
34
|
+
// for replacements, which arrive later, once the new module has actually loaded.
|
|
35
|
+
let generation = 0;
|
|
36
|
+
const swapListeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
subscribeRoutes(() => onRoutesChanged());
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rebuild the lookup and drop pages that no longer exist. A page that still exists keeps its
|
|
42
|
+
* entry even if its content changed: the version check in `loadPage` handles that, and it
|
|
43
|
+
* keeps the old module on screen until the new one is ready rather than dropping to a
|
|
44
|
+
* loading fallback. Exported for the pages test.
|
|
45
|
+
*/
|
|
46
|
+
export function onRoutesChanged(): void {
|
|
25
47
|
index = buildIndex();
|
|
26
48
|
// Deleting the current entry while iterating a Map is well defined.
|
|
27
49
|
for (const url of cache.keys()) {
|
|
28
50
|
if (!index.has(url)) cache.delete(url);
|
|
29
51
|
}
|
|
30
|
-
}
|
|
52
|
+
}
|
|
31
53
|
|
|
32
54
|
function buildIndex(): Map<string, RouteEntry> {
|
|
33
55
|
return new Map(getRoutes().map((entry) => [entry.url, entry]));
|
|
@@ -47,11 +69,61 @@ export function useRouteEntry(url: string): RouteEntry | undefined {
|
|
|
47
69
|
return entries.find((entry) => entry.url === url);
|
|
48
70
|
}
|
|
49
71
|
|
|
72
|
+
/** The page's module, re-rendering when an edit replaces it. Suspends until first loaded. */
|
|
73
|
+
export function usePageModule(entry: RouteEntry): PageModule {
|
|
74
|
+
useSyncExternalStore(subscribeSwaps, getGeneration, getGeneration);
|
|
75
|
+
return use(loadPage(entry) as Promise<PageModule>);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Exported for the pages test; components go through `usePageModule`. */
|
|
79
|
+
export function subscribeSwaps(listener: () => void): () => void {
|
|
80
|
+
swapListeners.add(listener);
|
|
81
|
+
return () => {
|
|
82
|
+
swapListeners.delete(listener);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getGeneration(): number {
|
|
87
|
+
return generation;
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
export function loadPage(entry: RouteEntry): Tracked {
|
|
51
91
|
const existing = cache.get(entry.url);
|
|
52
|
-
if (existing
|
|
92
|
+
if (existing === undefined) {
|
|
93
|
+
const fresh = track(entry);
|
|
94
|
+
cache.set(entry.url, fresh);
|
|
95
|
+
return fresh;
|
|
96
|
+
}
|
|
97
|
+
if (existing.version === entry.version) return existing;
|
|
98
|
+
|
|
99
|
+
// Only a rendered module is worth keeping on screen while its replacement loads. A pending
|
|
100
|
+
// or failed one is not: hand over immediately, so a fixed file suspends on the fix instead
|
|
101
|
+
// of re-throwing the error it just corrected.
|
|
102
|
+
if (existing.status !== 'fulfilled') {
|
|
103
|
+
const fresh = track(entry);
|
|
104
|
+
cache.set(entry.url, fresh);
|
|
105
|
+
return fresh;
|
|
106
|
+
}
|
|
53
107
|
|
|
108
|
+
if (existing.next?.version !== entry.version) {
|
|
109
|
+
const fresh = track(entry);
|
|
110
|
+
existing.next = fresh;
|
|
111
|
+
const settle = () => {
|
|
112
|
+
// The page may have been deleted, or this URL may already be on a later version — the
|
|
113
|
+
// next render compares versions again, so the only wrong move is resurrecting a URL.
|
|
114
|
+
if (!index.has(entry.url)) return;
|
|
115
|
+
cache.set(entry.url, fresh);
|
|
116
|
+
generation += 1;
|
|
117
|
+
for (const listener of swapListeners) listener();
|
|
118
|
+
};
|
|
119
|
+
fresh.then(settle, settle);
|
|
120
|
+
}
|
|
121
|
+
return existing;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function track(entry: RouteEntry): Tracked {
|
|
54
125
|
const promise = entry.load() as Tracked;
|
|
126
|
+
promise.version = entry.version;
|
|
55
127
|
promise.status = 'pending';
|
|
56
128
|
promise.then(
|
|
57
129
|
(value) => {
|
|
@@ -63,8 +135,6 @@ export function loadPage(entry: RouteEntry): Tracked {
|
|
|
63
135
|
promise.reason = reason;
|
|
64
136
|
},
|
|
65
137
|
);
|
|
66
|
-
|
|
67
|
-
cache.set(entry.url, promise);
|
|
68
138
|
return promise;
|
|
69
139
|
}
|
|
70
140
|
|
package/src/app/router.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Suspense } from 'react';
|
|
1
|
+
import { Component, Suspense, type ReactNode } from 'react';
|
|
2
2
|
import { useLocation, type RouteObject } from 'react-router';
|
|
3
3
|
import { decodePath } from '../shared/base.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { RouteEntry } from '../shared/types.js';
|
|
5
|
+
import { DocPage, DocsLayout, NotFound, Overview, PageError } from './layout/DocsLayout.js';
|
|
5
6
|
import { useRouteEntry } from './lib/pages.js';
|
|
6
7
|
|
|
7
8
|
/** The current route URL: React Router has already removed the basename. */
|
|
@@ -14,12 +15,50 @@ export function useRouteUrl(): string {
|
|
|
14
15
|
function Page() {
|
|
15
16
|
const url = useRouteUrl();
|
|
16
17
|
const entry = useRouteEntry(url);
|
|
18
|
+
// Keyed by address, so navigating away from a page that threw starts clean rather than
|
|
19
|
+
// carrying its error to every page after it. Reset by content version, so in dev an edit
|
|
20
|
+
// that fixes the file gets to render — a boundary in its error state has unmounted the
|
|
21
|
+
// children, and nothing else is left in the tree to try again.
|
|
22
|
+
return (
|
|
23
|
+
<PageErrorBoundary key={url} resetKey={entry?.version}>
|
|
24
|
+
<PageContent url={url} entry={entry} />
|
|
25
|
+
</PageErrorBoundary>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function PageContent({ url, entry }: { url: string; entry: RouteEntry | undefined }) {
|
|
17
30
|
if (entry !== undefined) return <DocPage entry={entry} />;
|
|
18
31
|
// A folder with no `index.md` or root `README.md` still gets a home address: a generated
|
|
19
32
|
// list of every page, not an apology.
|
|
20
33
|
return url === '/' ? <Overview /> : <NotFound />;
|
|
21
34
|
}
|
|
22
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The only error boundary in the app. Render errors come from page content — the rest of the
|
|
38
|
+
* tree is seemore's own — so this sits around the page and nothing else.
|
|
39
|
+
*/
|
|
40
|
+
class PageErrorBoundary extends Component<
|
|
41
|
+
{ children: ReactNode; resetKey: string | undefined },
|
|
42
|
+
{ message: string | undefined }
|
|
43
|
+
> {
|
|
44
|
+
override state: { message: string | undefined } = { message: undefined };
|
|
45
|
+
|
|
46
|
+
static getDerivedStateFromError(error: unknown): { message: string } {
|
|
47
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override componentDidUpdate(previous: { resetKey: string | undefined }): void {
|
|
51
|
+
if (this.state.message !== undefined && previous.resetKey !== this.props.resetKey) {
|
|
52
|
+
this.setState({ message: undefined });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override render() {
|
|
57
|
+
if (this.state.message !== undefined) return <PageError message={this.state.message} />;
|
|
58
|
+
return this.props.children;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
23
62
|
/**
|
|
24
63
|
* One catch-all route, matched against `virtual:seemore/routes` at render time.
|
|
25
64
|
*
|
package/src/shared/types.ts
CHANGED