151 lines
6.4 KiB
TypeScript
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");
|
|
}
|