/**
* HTML rendering stage.
*
* Renders entry pages, optional indexes, and a static `404.html`.
*/
import { mkdir } from "node:fs/promises";
import * as path from "node:path";
import type { Logger } from "../../logging/logger";
import type { WebetteEnv } from "../../config/env";
import type { Collection, Entry, Site, Block } from "../model";
import { resolveTemplatesRoots } from "../template";
import { createTemplateFileResolver } from "../templates/resolve-file";
import { getPrevNextEntry } from "./prev-next";
type TemplateRenderer = {
render: (context: Record) => string;
};
export async function renderSite(options: {
rootDir: string;
outputDir: string;
env: WebetteEnv;
site: Site;
templatesDefault: WebetteEnv["templates"]["default"];
templatesCustom?: {
index?: {
root?: string;
collections?: string;
collection?: Record;
};
entry?: Record;
};
siteTemplatesRoot?: string;
includeDrafts: boolean;
generateIndex: boolean;
layoutPartialName: string;
accessForbiddenLayoutName: string;
notFoundLayoutName: string;
writeIfChanged: (targetPath: string, content: string) => Promise;
formatHtml: (rawHtml: string, targetPath: string) => Promise;
templateRenderer: TemplateRenderer;
renderBaseContext: Record;
rootIndexTarget?: { collection: Collection; entry: Entry };
collectionIndexTargets: Map<
string,
{ target: Collection; entry: Entry; entryCollection: Collection }
>;
overrideEntryKeys: Set;
logger: Logger;
}): Promise<{ renderedPages: number }> {
const {
rootDir,
outputDir,
env,
site,
templatesDefault,
templatesCustom,
siteTemplatesRoot,
includeDrafts,
generateIndex,
layoutPartialName,
accessForbiddenLayoutName,
notFoundLayoutName,
writeIfChanged,
formatHtml,
templateRenderer,
renderBaseContext,
rootIndexTarget,
collectionIndexTargets,
overrideEntryKeys,
logger,
} = options;
let renderedPages = 0;
const layoutNameFor = (value: string): string => path.parse(value).name || value;
const filterRenderableBlocks = (blocks: Block[]): Block[] =>
blocks
.filter((b) => !b.skipRender)
.map((b) => ({
...b,
...(b.subBlocks ? { subBlocks: filterRenderableBlocks(b.subBlocks) } : {}),
}));
const entryLayoutOverrides =
templatesCustom?.entry && Object.keys(templatesCustom.entry).length
? templatesCustom.entry
: undefined;
const entryCollectionById = new Map();
for (const collection of site.collections ?? []) {
for (const entry of collection.entries) {
entryCollectionById.set(entry.id, collection);
}
}
const templatesRootsForLayouts = await resolveTemplatesRoots({
rootDir,
templatesRoot: env.templates.root,
siteTemplatesRoot,
logger,
});
const fileResolver = createTemplateFileResolver({
rootDir,
templatesRoots: templatesRootsForLayouts,
logger,
});
const loggedEntryLayouts = new Set();
const warnedEntryLayouts = new Set();
const pickEntryLayoutName = async (
entry: Entry,
renderCollection: Collection | undefined
): Promise => {
if (!entryLayoutOverrides) return layoutPartialName;
const sourceCollection = entryCollectionById.get(entry.id) ?? renderCollection;
if (!sourceCollection) return layoutPartialName;
const key = `${sourceCollection.slug || sourceCollection.id}/${entry.slug || entry.id}`;
const overridePath = entryLayoutOverrides[key];
if (!overridePath) return layoutPartialName;
const resolved = await fileResolver.resolve(overridePath);
if (resolved) {
if (!loggedEntryLayouts.has(overridePath)) {
loggedEntryLayouts.add(overridePath);
await fileResolver.logResolved({
kind: "entry.layout",
relPath: overridePath,
extra: { key },
});
}
return layoutNameFor(overridePath);
}
if (!warnedEntryLayouts.has(overridePath)) {
warnedEntryLayouts.add(overridePath);
logger.warn("template.notFound", { paths: fileResolver.triedPaths(overridePath).join(", ") });
}
return layoutPartialName;
};
const renderEntryTo = async (
entry: Entry,
collection: Collection | undefined,
targetDir: string
): Promise => {
const renderableBlocks = filterRenderableBlocks(entry.blocks);
const layoutName = await pickEntryLayoutName(entry, collection);
const { prevEntry, nextEntry } = getPrevNextEntry({
site,
entry,
renderCollection: collection,
includeDrafts,
});
const context = {
...renderBaseContext,
layoutName,
...(collection ? { collection } : {}),
entry,
blocks: renderableBlocks,
...(prevEntry ? { prevEntry } : {}),
...(nextEntry ? { nextEntry } : {}),
};
const html = templateRenderer.render(context);
await mkdir(targetDir, { recursive: true });
const entryIndexPath = path.join(targetDir, "index.html");
const finalHtml = await formatHtml(html, entryIndexPath);
return writeIfChanged(entryIndexPath, finalHtml);
};
// Entry pages
for (const collection of site.collections ?? []) {
for (const entry of collection.entries) {
if (!includeDrafts && entry.isDraft) continue;
const entryKey = `${collection.slug || collection.id}::${entry.slug || entry.id}`;
if (overrideEntryKeys.has(entryKey)) continue;
const entryRouteRelative = entry.route
? entry.route.replace(/^\/+/, "")
: path.join(collection.slug || collection.id, entry.slug || entry.id);
const entryOutputDir = path.join(outputDir, entryRouteRelative);
const wrote = await renderEntryTo(entry, collection, entryOutputDir);
if (wrote) renderedPages += 1;
}
}
// Indexes
if (generateIndex) {
const pickIndexLayout = async (kind: string, candidates: string[]): Promise => {
const triedPaths: string[] = [];
for (const candidate of candidates) {
if (!candidate) continue;
triedPaths.push(...fileResolver.triedPaths(candidate));
const resolved = await fileResolver.logResolved({ kind, relPath: candidate });
if (resolved) return layoutNameFor(candidate);
}
if (triedPaths.length) {
logger.warn("template.notFound", { paths: triedPaths.join(", ") });
}
return layoutNameFor(templatesDefault.index);
};
const customIndex = templatesCustom?.index;
// Root index
if (rootIndexTarget) {
const entryForIndex: Entry = { ...rootIndexTarget.entry, route: "/" };
const wrote = await renderEntryTo(entryForIndex, undefined, outputDir);
if (wrote) renderedPages += 1;
} else {
const rootIndexLayoutName = await pickIndexLayout("index.root", [
customIndex?.root ?? "",
templatesDefault.index,
]);
const visibleCollections = (site.collections ?? [])
.filter((collection) => !collection.isUnlisted)
.map((collection) => ({
...collection,
entries: collection.entries.filter((entry) => !entry.isUnlisted),
}));
const rootContext = {
...renderBaseContext,
layoutName: rootIndexLayoutName,
entry: { name: "Index", route: "/" },
collections: visibleCollections,
};
const rootHtml = templateRenderer.render(rootContext);
const rootIndexPath = path.join(outputDir, "index.html");
await mkdir(path.dirname(rootIndexPath), { recursive: true });
const finalRootHtml = await formatHtml(rootHtml, rootIndexPath);
await writeIfChanged(rootIndexPath, finalRootHtml);
}
// Collection indexes
for (const collection of site.collections ?? []) {
if (collection.isUnlisted) {
const routeRelative = collection.route
? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
: collection.slug || collection.id;
const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
const collectionIndexPath = path.join(collectionIndexDir, "index.html");
const forbiddenContext = {
...renderBaseContext,
layoutName: accessForbiddenLayoutName,
entry: {
name: "Access forbidden",
route: collection.route ?? `/${collection.slug || collection.id}`,
},
collection,
};
const forbiddenHtml = templateRenderer.render(forbiddenContext);
await mkdir(collectionIndexDir, { recursive: true });
const finalForbidden = await formatHtml(forbiddenHtml, collectionIndexPath);
await writeIfChanged(collectionIndexPath, finalForbidden);
continue;
}
const visibleEntries = collection.entries.filter((entry) => !entry.isUnlisted);
const collectionForIndex = { ...collection, entries: visibleEntries };
const override = collectionIndexTargets.get(collection.slug || collection.id);
if (override) {
const routeRelative = collection.route
? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
: collection.slug || collection.id;
const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
const entryForIndex: Entry = {
...override.entry,
route: collection.route ?? `/${collection.slug || collection.id}/`,
};
const wrote = await renderEntryTo(entryForIndex, override.target, collectionIndexDir);
if (wrote) renderedPages += 1;
continue;
}
const collectionIndexLayoutName = await pickIndexLayout(
`index.collection.${collection.slug || collection.id}`,
[
customIndex?.collection?.[collection.slug || collection.id] ?? "",
customIndex?.collections ?? "",
templatesDefault.index,
]
);
const routeRelative = collection.route
? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
: collection.slug || collection.id;
const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
const collectionIndexPath = path.join(collectionIndexDir, "index.html");
const ctx = {
...renderBaseContext,
layoutName: collectionIndexLayoutName,
entry: { name: collection.name, route: collection.route },
collection: collectionForIndex,
entries: visibleEntries,
};
const colHtml = templateRenderer.render(ctx);
await mkdir(collectionIndexDir, { recursive: true });
const finalColHtml = await formatHtml(colHtml, collectionIndexPath);
await writeIfChanged(collectionIndexPath, finalColHtml);
}
}
// Static 404 (always)
{
const notFoundContext = {
...renderBaseContext,
layoutName: notFoundLayoutName,
entry: { name: "Not found", route: "/404" },
};
const notFoundHtml = templateRenderer.render(notFoundContext);
const notFoundPath = path.join(outputDir, "404.html");
await mkdir(path.dirname(notFoundPath), { recursive: true });
const finalNotFoundHtml = await formatHtml(notFoundHtml, notFoundPath);
await writeIfChanged(notFoundPath, finalNotFoundHtml);
}
return { renderedPages };
}