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