import { Writable } from 'node:stream'; import { StrictMode, type ComponentProps, type ReactNode } from 'react'; import { renderToPipeableStream } from 'react-dom/server'; import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router'; import { TabsContent } from 'fumadocs-ui/components/tabs'; import { config } from 'virtual:seemore/config'; import { toBasename, withBase } from '../shared/base.js'; import { ogImagePath } from '../shared/og.js'; import { mdxComponents } from './mdx/components.js'; import { createRouteObjects } from './router.js'; import { findRoute, preloadPage, routeEntries } from './lib/pages.js'; export interface RenderResult { html: string; head: string; } /** * The prerender driver. * * The page's module is loaded first, so `use()` resolves synchronously and the renderer * emits the complete article rather than a Suspense fallback. No route has a loader, so the * memory router is initialised the moment it is created. */ export async function render(url: string): Promise { await preloadPage(url); const router = createMemoryRouter(createRouteObjects(), { basename: toBasename(config.base), initialEntries: [withBase(config.base, url)], }); const { html, failures } = await renderToHtml( , ); // React hands a render error to the nearest Suspense boundary and carries on. Without this // the page would be written out as the loading fallback — an empty shell — and the build // would report it as a success. if (failures.length > 0) throw prerenderError(url, failures[0]); return { html, head: head(url) }; } /** * The whole tree as one string, plus anything that threw while rendering it. * * `renderToString` would be shorter, but it swallows render errors: its own `onError` is * internal and a failed subtree is silently replaced by its Suspense fallback. The streaming * renderer reports them, and piping only once `onAllReady` has fired keeps the output the * same complete markup — no fallbacks, no streaming scripts. */ function renderToHtml(element: ReactNode): Promise<{ html: string; failures: unknown[] }> { return new Promise((resolve) => { const failures: unknown[] = []; const chunks: Buffer[] = []; const sink = new Writable({ write(chunk: Buffer, _encoding, done) { chunks.push(Buffer.from(chunk)); done(); }, }); sink.on('finish', () => { resolve({ html: Buffer.concat(chunks).toString('utf8'), failures }); }); const stream = renderToPipeableStream(element, { onError(error: unknown) { failures.push(error); }, onAllReady() { stream.pipe(sink); }, // Nothing was rendered at all: there is no markup to hand back, and `failures` already // holds the reason. onShellError() { resolve({ html: '', failures }); }, }); }); } /** * Components an MDX file can use without importing anything. * * Adding one that hides or defers content by default (as `CodeBlockTabs` does — Radix * mounts only the active panel) needs the export path updated in the same change, or the * new content goes missing from every exported file the way the tabs did: * `exportComponents` below, which force-mounts it for the prerender; `captureTabPanels` in * `exportPage.ts`, which harvests it for the browser export; and the runtime that wires the * static markup back up (`RUNTIME` in `exportPage.ts`, `bindBehaviors` in `standalone.ts`). */ const PROVIDED_COMPONENTS = 'Callout, Card, Cards, CodeBlockTabs, Mermaid, D2 and Pdf'; /** * A page that threw, reported the way the rest of the build reports problems: the file it * came from, what went wrong, and — for the common case of an MDX file reaching for a * component that is not there — the fix. */ function prerenderError(url: string, cause: unknown): Error { const file = findRoute(url)?.file; const message = cause instanceof Error ? cause.message : String(cause); const undefinedComponent = /Expected component `(.+?)` to be defined/.exec(message); const hint = undefinedComponent === null ? '' : `\n\n \`<${undefinedComponent[1]}>\` is not one of the components seemore provides ` + `(${PROVIDED_COMPONENTS}), and a Markdown file has no imports to add one with. ` + `Remove it, or write the markup by hand.`; return new Error(`${file ?? url} failed to render.\n\n ${message}${hint}`, { cause }); } export function listRoutes(): string[] { return routeEntries().map((entry) => entry.url); } /** What `renderArticle` hands back: the content and the metadata a title bar needs. */ export interface ExportedArticle { html: string; title: string; description?: string; } /** * The article alone — the MDX content, no layout, no sidebar, no chrome — for the * single-page export. Rendering the component directly, rather than extracting the * article from a full-page render, means no HTML parsing anywhere in the export path. * * Tabs render force-mounted: Radix keeps only the active panel's content in the tree, and * a file with three of its four tabs empty is no export at all. Force-mounted panels hide * through fumadocs' own `data-[state=inactive]:hidden` rule, which the stylesheet the * export inlines already carries, and the file's runtime does the switching. * * Diagrams are absent here, as in every prerendered page (see `Mermaid.tsx`); the CLI * export inlines a runtime that renders them when the file is opened. */ export async function renderArticle(url: string): Promise { const entry = findRoute(url); if (entry === undefined) throw new Error(`No page at ${url}.`); const page = await preloadPage(url); if (page === undefined) throw new Error(`No page at ${url}.`); const Content = page.default; const exportComponents = { ...mdxComponents, CodeBlockTab: (props: ComponentProps) => , }; // A router is still required: content links go through react-router's `Link`, which // reads the routing context. A memory router with just this page is the smallest one. const { html, failures } = await renderToHtml( , ); if (failures.length > 0) throw prerenderError(url, failures[0]); return { html, title: entry.title, description: entry.description ?? undefined }; } function head(url: string): string { const entry = findRoute(url); const title = entry === undefined || url === '/' ? config.title : `${entry.title} · ${config.title}`; const description = entry?.description ?? config.description; const tags = [`${escapeHtml(title)}`]; if (description != null) tags.push(``); if (config.favicon !== undefined) tags.push(``); tags.push(``); if (description != null) tags.push(``); if (config.features['social.cards']) { const card = escapeHtml(withBase(config.base, ogImagePath(url))); tags.push(``); tags.push(``); tags.push(``); } return tags.join('\n '); } function escapeHtml(value: string): string { return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }