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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Let AI write the Markdown. Let seemore show it better — zero config documentation framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
- import { StrictMode } from 'react';
2
- import { renderToString } from 'react-dom/server';
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 `renderToString`
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 = renderToString(
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 { PageModule, RouteEntry } from '../../shared/types.js';
8
- import { loadPage } from '../lib/pages.js';
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 synchronously, which
47
- // is what makes `renderToString` emit a complete page.
48
- const page = use(loadPage(entry) as Promise<PageModule>);
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.
@@ -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
- // 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(() => {
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 !== undefined) return 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
 
@@ -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 { DocPage, DocsLayout, NotFound, Overview } from './layout/DocsLayout.js';
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
  *
@@ -62,6 +62,8 @@ export interface RouteEntry {
62
62
  absPath: string;
63
63
  title: string;
64
64
  description: string | null;
65
+ /** Content hash; a new value means `load()` now resolves to a different module. */
66
+ version: string;
65
67
  load: () => Promise<PageModule>;
66
68
  }
67
69