import { readFile, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { readFileSync, statSync } from "node:fs"; import React, { type ReactNode } from "react"; import { renderToString } from "react-dom/server"; import { compileMdx, frontmatterField } from "./mdx"; import { DEFAULT_FAVICON, getContentType } from "./utils"; import { DOCS_DIR, DIST_DIR, PAGES_DIR, PROJECT_ROOT } from "./paths"; import { BuildPluginBuilder } from "./plugin-builder"; import type { PageContext, PageType } from "./plugin"; import type { AssetEntry, AssetManifest, DocuConfig, TocItem } from "./types"; import DocsPage from "../pages/docs/[[...slug]]"; import NotFoundPage from "../pages/404"; import IndexPage from "../pages/index"; import { DocsLayout } from "../components/DocsLayout"; import { generateNonce, isPathSafe, isSlugSafe, htmlResponse, SECURITY_HEADERS } from "./security"; import { htmlShell as createHtmlShell, hmrScript, errorHtml } from "./html.shared"; export interface ServerState { docuConfig: DocuConfig; assetManifest: AssetManifest; inlineThemeCss?: string; builder: BuildPluginBuilder | null; } async function createHtmlResponse( title: string, description: string, body: string, status: number, state: ServerState, pageType: PageType, page: Omit, depth = 0 ): Promise { const nonce = generateNonce(); const favicon = state.docuConfig.meta?.favicon || DEFAULT_FAVICON; const assets: AssetEntry = state.assetManifest[pageType]; const context: PageContext = { ...page, pageType, assets, config: state.docuConfig, }; let html = createHtmlShell({ title, description, body, favicon, css: assets.css, js: assets.js, nonce, extraScripts: hmrScript(nonce), themeCss: state.inlineThemeCss, headExtra: state.builder?.collectHead(context), bodyExtra: state.builder?.collectBody(context), depth, // 404 pages can be requested at arbitrary depths (e.g. a noLink section // path typed in the address bar) — relative asset paths would resolve // against the wrong directory and break CSS/JS. absoluteAssets: status === 404, }); if (state.builder) html = await state.builder.runTransformHtmlChain(html, context); /** Dev-only: serves compiledSource → MDXRemote eval path (no CSP in production). */ return htmlResponse(html, nonce, status, true); } // Dev-only memo: compileMdx per request is ~70ms; repeat navigations with // an unchanged file (same mtime) reuse the compiled result. The dev server // has no other per-page cache — without this every navigation re-parses MDX. // ponytail: unbounded Map is fine — docs sites have a handful of pages; cap // with an LRU only if a project exceeds thousands of routes. const docsCache = new Map< string, { mtimeMs: number; doc: NonNullable>> } >(); async function getDocsForSlug( slug: string, state: ServerState ): Promise<{ content: ReactNode; compiledSource: string; frontmatter: Record; tocs: TocItem[]; filePath: string; resolvedContent: string; } | null> { if (!isSlugSafe(slug, DOCS_DIR)) return null; const paths = [ resolve(DOCS_DIR, slug, "index.mdx"), resolve(DOCS_DIR, `${slug}.mdx`), resolve(DOCS_DIR, slug, "index.md"), resolve(DOCS_DIR, `${slug}.md`), ]; const resolvedDocsDir = resolve(DOCS_DIR); let filePath: string | null = null; let raw: string | null = null; for (const p of paths) { const resolvedCandidate = resolve(p); if ( resolvedCandidate !== resolvedDocsDir && !resolvedCandidate.startsWith(resolvedDocsDir + "/") ) { continue; } try { raw = await readFile(resolvedCandidate, "utf-8"); filePath = resolvedCandidate; break; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; } } if (!filePath || !raw) return null; const relPath = filePath.replace(PROJECT_ROOT + "/", ""); const mtimeMs = (await stat(filePath)).mtimeMs; const cached = docsCache.get(relPath); if (cached && cached.mtimeMs === mtimeMs) { return cached.doc; } let content = raw; if (state.builder) { const transformed = await state.builder.runOnLoad(relPath, content); if (transformed?.contents) { content = transformed.contents; } } const remarkPlugins = state.builder?.collectRemarkPlugins(); const rehypePlugins = state.builder?.collectRehypePlugins(); const result = await compileMdx(content, relPath, undefined, remarkPlugins, rehypePlugins); let frontmatter = result.frontmatter as Record; if (state.builder) { frontmatter = await state.builder.runTransformFrontmatterChain(frontmatter, { slug, filePath: relPath, content, }); } const doc = { content: result.content, compiledSource: result.compiledSource, frontmatter, tocs: result.tocs, filePath: relPath, resolvedContent: content, }; docsCache.set(relPath, { mtimeMs, doc }); return doc; } async function renderDocsServerPage( doc: NonNullable>>, slug: string[], pathname: string, state: ServerState ): Promise { const title = frontmatterField(doc.frontmatter, "title") || slug.join("/") || "Docs"; const description = frontmatterField(doc.frontmatter, "description"); const page = React.createElement( DocsLayout, { repoUrl: state.docuConfig.repo?.url, pathname }, React.createElement(DocsPage, { slug, title, description, date: frontmatterField(doc.frontmatter, "date") || undefined, // Same root-relative SSR as build — see build.ts. content: renderToString(doc.content), tocs: doc.tocs, filePath: doc.filePath, repoUrl: state.docuConfig.repo?.url, compiledSource: doc.compiledSource, }) ); const body = renderToString(page); // Match build.ts depth calculation: slug.split("/").length, fallback to 1 for empty const depth = slug.length || 1; return createHtmlResponse( title, description, body, 200, state, "docs", { slug: slug.join("/"), filePath: doc.filePath, frontmatter: doc.frontmatter, content: doc.resolvedContent, }, depth ); } async function renderPage( Component: React.ComponentType>, title: string, description: string, status: number, state: ServerState, props: Record = {}, depth = 0 ): Promise { const body = renderToString(React.createElement(Component, props)); return createHtmlResponse( title, description, body, status, state, "notFound", { slug: "404", filePath: resolve(PAGES_DIR, "404.tsx"), frontmatter: {}, }, depth ); } export async function handleDocsIndex(state: ServerState): Promise { const doc = await getDocsForSlug("", state); if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state, {}, 1); return renderDocsServerPage(doc, [], "/docs", state); } export async function handleDocsRoute(slug: string[], state: ServerState): Promise { const path = slug.join("/"); const doc = await getDocsForSlug(path, state); if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state, {}, slug.length || 1); return renderDocsServerPage(doc, slug, `/docs/${path}`, state); } export async function handleIndex(state: ServerState): Promise { const page = React.createElement(IndexPage); const body = renderToString(page); return createHtmlResponse( state.docuConfig.meta?.title || "DocuBook", state.docuConfig.meta?.description || "", body, 200, state, "home", { slug: "", filePath: resolve(PAGES_DIR, "index.tsx"), frontmatter: { ...state.docuConfig.meta } as Record, } ); } export async function handleNotFound(state: ServerState, depth = 0): Promise { return renderPage(NotFoundPage, "404 - Not Found", "", 404, state, {}, depth); } export function serveStatic(pathname: string): Response | null { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return null; } if (!isPathSafe(pathname, DIST_DIR)) return null; const assetPath = resolve(DIST_DIR, decoded.slice(1)); try { const s = statSync(assetPath); if (s.isFile()) { return new Response(readFileSync(assetPath), { headers: { "Content-Type": getContentType(pathname) }, }); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; } if (decoded.startsWith("/docs/assets/")) { const docsAssetsDir = resolve(DOCS_DIR, "assets"); const requestedRelative = decoded.slice("/docs/assets/".length); const docsAsset = resolve(docsAssetsDir, requestedRelative); const docsAssetsDirWithSep = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/"; if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirWithSep)) return null; try { const s = statSync(docsAsset); if (s.isFile()) { return new Response(readFileSync(docsAsset), { headers: { "Content-Type": getContentType(pathname) }, }); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; } } return null; } export function serverErrorResponse(error: unknown): Response { const msg = error instanceof Error ? error.message : "Unknown error"; const st = process.env.NODE_ENV !== "production" && error instanceof Error ? error.stack : undefined; return new Response(errorHtml(msg, st), { status: 500, headers: { "Content-Type": "text/html", ...SECURITY_HEADERS, "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'", }, }); }