muddle/adapters/static.ts
2026-10-23 09:12:49 -04:00

201 lines
6.6 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");
}