muddle/adapters/static.ts
2026-10-23 08:53:07 -04:00

151 lines
6.4 KiB
TypeScript

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[]> | 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<PrerenderResult[]> {
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<string> {
for (const match of [...html.matchAll(/<link\b[^>]*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], `<style>${await response.text()}</style>`);
}
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");
}