import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { App } from "../core/app.ts"; import { getRenderAhead } from "../decorators/RenderAhead.ts"; import { getRenderRoutes, type RenderRouteSource } from "../decorators/RenderRoute.ts"; import { ContentRegistry } from "../core/content.ts"; import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts"; export interface PrerenderOptions { /** Routes that are rendered at build time. */ routes?: string[]; /** Directory the html files are written to. */ outDir: string; /** Hostname used while rendering, defaults to `localhost`. */ hostname?: string; /** Protocol used while rendering. */ protocol?: string; /** Adds discovered dynamic routes before rendering. */ discover?: (pattern: string) => Promise | string[]; progress?: (event: { current: number; total: number; route: string }) => void; predict?: LayoutPredictionOptions; } export interface PrerenderResult { /** Rendered route. */ route: string; /** File the html was written to. */ file: string; /** Status code of the response. */ status: number; } /** Renders the routes of the application into static html files. */ export async function prerender( app: App, options: PrerenderOptions, ): Promise { const outDir = resolve(options.outDir); const stageDir = `${outDir}.staging`; await rm(stageDir, { recursive: true, force: true }); await mkdir(stageDir, { recursive: true }); const origin = `${options.protocol ?? "http"}://${options.hostname ?? "localhost"}`; const results: PrerenderResult[] = []; const routes = new Set(options.routes ?? []); const content = app.resolvedConfig ? new ContentRegistry(app.resolvedConfig) : undefined; if (content) await content.load(); for (const route of app.router.list()) { if (route.method !== "GET") continue; if (!route.path.includes(":")) routes.add(route.path); else { for (const source of getRenderRoutes(route.controller, route.methodName)) { for (const path of resolveRenderRoute(source, content)) routes.add(path); } const ahead = getRenderAhead(route.controller, route.methodName); const values = ahead ? (typeof ahead === "function" ? await ahead() : ahead) : await options.discover?.(route.path); for (const value of values ?? []) routes.add(value.startsWith("/") ? value : route.path.replace(/:[^/]+\??/, value)); } } const orderedRoutes = [...routes].sort(); for (const [index, route] of orderedRoutes.entries()) { if (app.router.match("GET", route).kind === "not-found") { throw new Error(`Cannot prerender unknown route "${route}"`); } options.progress?.({ current: index + 1, total: orderedRoutes.length, route }); const request = new Request(new URL(route, origin).toString()); const response = await app.fetch(request); const contentType = response.headers.get("content-type") ?? ""; if (response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); if (location) { console.log(`muddle: ${route} redirects to ${location}, skipped`); } continue; } if (!contentType.includes("text/html")) { continue; } let html = await response.text(); // Relation anchors and STCSS prediction markers are static inputs to the // layout pass. Run Playwright automatically so consumers never need to // ship a geometry bridge merely because a page uses `$refer`. const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); if (options.predict || hasRelations) { const assetHtml = await inlinePredictionStyles(app, html, origin); html = (await predictLayout(assetHtml, options.predict ?? {})).html; } const file = fileFor(stageDir, route); await mkdir(dirname(file), { recursive: true }); const asset = await app.transformAsset({ path: file, contentType, content: html }); const reports = await app.analyzePage({ route, html: await inlinePredictionStyles(app, html, origin) }); if (Object.keys(reports).length) await writeFile(`${file}.analysis.json`, JSON.stringify(reports, null, 2)); await writeFile(file, asset.content); results.push({ route, file, status: response.status }); } for (const route of app.router.listRaw()) { if (route.method !== "GET") continue; if (route.path.includes("*")) continue; const request = new Request(new URL(route.path, origin).toString()); const response = await app.fetch(request); const file = join(stageDir, route.path.replace(/^\//, "")); await mkdir(dirname(file), { recursive: true }); const contentType = response.headers.get("content-type") ?? "application/octet-stream"; const asset = await app.transformAsset({ path: file, contentType, content: new Uint8Array(await response.arrayBuffer()) }); await writeFile(file, asset.content); results.push({ route: route.path, file, status: response.status }); } const backup = `${outDir}.previous`; await rm(backup, { recursive: true, force: true }); try { await rename(outDir, backup); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } await rename(stageDir, outDir); return results; } /** Inlines same-origin styles so Playwright can measure a complete document. */ export async function inlinePredictionStyles(app: App, html: string, origin: string): Promise { for (const match of [...html.matchAll(/]*href="([^"]+)"[^>]*>/g)]) { const url = new URL(match[1]!, origin); if (url.origin !== origin || !match[0].includes('rel="stylesheet"')) continue; const response = await app.fetch(new Request(url.href)); if (response.ok) html = html.replace(match[0], ``); } return html; } function resolveRenderRoute(source: RenderRouteSource, content?: ContentRegistry): string[] { if (typeof source === "string") return [source]; if (Array.isArray(source)) return source; return (content?.entries(source.collection) ?? []).map((entry) => source.path.replace(/:id\??/g, encodeURIComponent(entry.id))); } /** Path of the html file of a route. */ export function fileFor(outDir: string, route: string): string { const clean = route.split("?")[0] ?? "/"; if (clean === "/" || clean === "") return join(outDir, "index.html"); const segments = clean.split("/").filter((segment) => segment.length > 0); const last = segments.pop(); if (segments.length === 0) { return join(outDir, `${last as string}.html`); } return join(outDir, ...segments, last as string, "index.html"); }