1/**
2 * HTML rendering stage.
3 *
4 * Renders entry pages, optional indexes, and a static `404.html`.
5 */
6
7import { mkdir } from "node:fs/promises";
8import * as path from "node:path";
9import type { Logger } from "../../logging/logger";
10import type { WebetteEnv } from "../../config/env";
11import type { Collection, Entry, Site, Block } from "../model";
12import { resolveTemplatesRoots } from "../template";
13import { createTemplateFileResolver } from "../templates/resolve-file";
14import { getPrevNextEntry } from "./prev-next";
15
16type TemplateRenderer = {
17 render: (context: Record<string, unknown>) => string;
18};
19
20export async function renderSite(options: {
21 rootDir: string;
22 outputDir: string;
23 env: WebetteEnv;
24 site: Site;
25 templatesDefault: WebetteEnv["templates"]["default"];
26 templatesCustom?: {
27 index?: {
28 root?: string;
29 collections?: string;
30 collection?: Record<string, string>;
31 };
32 entry?: Record<string, string>;
33 };
34 siteTemplatesRoot?: string;
35 includeDrafts: boolean;
36 generateIndex: boolean;
37 layoutPartialName: string;
38 accessForbiddenLayoutName: string;
39 notFoundLayoutName: string;
40 writeIfChanged: (targetPath: string, content: string) => Promise<boolean>;
41 formatHtml: (rawHtml: string, targetPath: string) => Promise<string>;
42 templateRenderer: TemplateRenderer;
43 renderBaseContext: Record<string, unknown>;
44 rootIndexTarget?: { collection: Collection; entry: Entry };
45 collectionIndexTargets: Map<
46 string,
47 { target: Collection; entry: Entry; entryCollection: Collection }
48 >;
49 overrideEntryKeys: Set<string>;
50 logger: Logger;
51}): Promise<{ renderedPages: number }> {
52 const {
53 rootDir,
54 outputDir,
55 env,
56 site,
57 templatesDefault,
58 templatesCustom,
59 siteTemplatesRoot,
60 includeDrafts,
61 generateIndex,
62 layoutPartialName,
63 accessForbiddenLayoutName,
64 notFoundLayoutName,
65 writeIfChanged,
66 formatHtml,
67 templateRenderer,
68 renderBaseContext,
69 rootIndexTarget,
70 collectionIndexTargets,
71 overrideEntryKeys,
72 logger,
73 } = options;
74
75 let renderedPages = 0;
76
77 const layoutNameFor = (value: string): string => path.parse(value).name || value;
78
79 const filterRenderableBlocks = (blocks: Block[]): Block[] =>
80 blocks
81 .filter((b) => !b.skipRender)
82 .map((b) => ({
83 ...b,
84 ...(b.subBlocks ? { subBlocks: filterRenderableBlocks(b.subBlocks) } : {}),
85 }));
86
87 const entryLayoutOverrides =
88 templatesCustom?.entry && Object.keys(templatesCustom.entry).length
89 ? templatesCustom.entry
90 : undefined;
91
92 const entryCollectionById = new Map<string, Collection>();
93 for (const collection of site.collections ?? []) {
94 for (const entry of collection.entries) {
95 entryCollectionById.set(entry.id, collection);
96 }
97 }
98
99 const templatesRootsForLayouts = await resolveTemplatesRoots({
100 rootDir,
101 templatesRoot: env.templates.root,
102 siteTemplatesRoot,
103 logger,
104 });
105 const fileResolver = createTemplateFileResolver({
106 rootDir,
107 templatesRoots: templatesRootsForLayouts,
108 logger,
109 });
110
111 const loggedEntryLayouts = new Set<string>();
112 const warnedEntryLayouts = new Set<string>();
113
114 const pickEntryLayoutName = async (
115 entry: Entry,
116 renderCollection: Collection | undefined
117 ): Promise<string> => {
118 if (!entryLayoutOverrides) return layoutPartialName;
119 const sourceCollection = entryCollectionById.get(entry.id) ?? renderCollection;
120 if (!sourceCollection) return layoutPartialName;
121
122 const key = `${sourceCollection.slug || sourceCollection.id}/${entry.slug || entry.id}`;
123 const overridePath = entryLayoutOverrides[key];
124 if (!overridePath) return layoutPartialName;
125
126 const resolved = await fileResolver.resolve(overridePath);
127 if (resolved) {
128 if (!loggedEntryLayouts.has(overridePath)) {
129 loggedEntryLayouts.add(overridePath);
130 await fileResolver.logResolved({
131 kind: "entry.layout",
132 relPath: overridePath,
133 extra: { key },
134 });
135 }
136 return layoutNameFor(overridePath);
137 }
138
139 if (!warnedEntryLayouts.has(overridePath)) {
140 warnedEntryLayouts.add(overridePath);
141 logger.warn("template.notFound", { paths: fileResolver.triedPaths(overridePath).join(", ") });
142 }
143
144 return layoutPartialName;
145 };
146
147 const renderEntryTo = async (
148 entry: Entry,
149 collection: Collection | undefined,
150 targetDir: string
151 ): Promise<boolean> => {
152 const renderableBlocks = filterRenderableBlocks(entry.blocks);
153 const layoutName = await pickEntryLayoutName(entry, collection);
154 const { prevEntry, nextEntry } = getPrevNextEntry({
155 site,
156 entry,
157 renderCollection: collection,
158 includeDrafts,
159 });
160 const context = {
161 ...renderBaseContext,
162 layoutName,
163 ...(collection ? { collection } : {}),
164 entry,
165 blocks: renderableBlocks,
166 ...(prevEntry ? { prevEntry } : {}),
167 ...(nextEntry ? { nextEntry } : {}),
168 };
169
170 const html = templateRenderer.render(context);
171 await mkdir(targetDir, { recursive: true });
172 const entryIndexPath = path.join(targetDir, "index.html");
173 const finalHtml = await formatHtml(html, entryIndexPath);
174 return writeIfChanged(entryIndexPath, finalHtml);
175 };
176
177 // Entry pages
178 for (const collection of site.collections ?? []) {
179 for (const entry of collection.entries) {
180 if (!includeDrafts && entry.isDraft) continue;
181 const entryKey = `${collection.slug || collection.id}::${entry.slug || entry.id}`;
182 if (overrideEntryKeys.has(entryKey)) continue;
183
184 const entryRouteRelative = entry.route
185 ? entry.route.replace(/^\/+/, "")
186 : path.join(collection.slug || collection.id, entry.slug || entry.id);
187 const entryOutputDir = path.join(outputDir, entryRouteRelative);
188
189 const wrote = await renderEntryTo(entry, collection, entryOutputDir);
190 if (wrote) renderedPages += 1;
191 }
192 }
193
194 // Indexes
195 if (generateIndex) {
196 const pickIndexLayout = async (kind: string, candidates: string[]): Promise<string> => {
197 const triedPaths: string[] = [];
198 for (const candidate of candidates) {
199 if (!candidate) continue;
200 triedPaths.push(...fileResolver.triedPaths(candidate));
201 const resolved = await fileResolver.logResolved({ kind, relPath: candidate });
202 if (resolved) return layoutNameFor(candidate);
203 }
204 if (triedPaths.length) {
205 logger.warn("template.notFound", { paths: triedPaths.join(", ") });
206 }
207 return layoutNameFor(templatesDefault.index);
208 };
209
210 const customIndex = templatesCustom?.index;
211
212 // Root index
213 if (rootIndexTarget) {
214 const entryForIndex: Entry = { ...rootIndexTarget.entry, route: "/" };
215 const wrote = await renderEntryTo(entryForIndex, undefined, outputDir);
216 if (wrote) renderedPages += 1;
217 } else {
218 const rootIndexLayoutName = await pickIndexLayout("index.root", [
219 customIndex?.root ?? "",
220 templatesDefault.index,
221 ]);
222
223 const visibleCollections = (site.collections ?? [])
224 .filter((collection) => !collection.isUnlisted)
225 .map((collection) => ({
226 ...collection,
227 entries: collection.entries.filter((entry) => !entry.isUnlisted),
228 }));
229
230 const rootContext = {
231 ...renderBaseContext,
232 layoutName: rootIndexLayoutName,
233 entry: { name: "Index", route: "/" },
234 collections: visibleCollections,
235 };
236 const rootHtml = templateRenderer.render(rootContext);
237 const rootIndexPath = path.join(outputDir, "index.html");
238 await mkdir(path.dirname(rootIndexPath), { recursive: true });
239 const finalRootHtml = await formatHtml(rootHtml, rootIndexPath);
240 await writeIfChanged(rootIndexPath, finalRootHtml);
241 }
242
243 // Collection indexes
244 for (const collection of site.collections ?? []) {
245 if (collection.isUnlisted) {
246 const routeRelative = collection.route
247 ? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
248 : collection.slug || collection.id;
249 const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
250 const collectionIndexPath = path.join(collectionIndexDir, "index.html");
251
252 const forbiddenContext = {
253 ...renderBaseContext,
254 layoutName: accessForbiddenLayoutName,
255 entry: {
256 name: "Access forbidden",
257 route: collection.route ?? `/${collection.slug || collection.id}`,
258 },
259 collection,
260 };
261 const forbiddenHtml = templateRenderer.render(forbiddenContext);
262 await mkdir(collectionIndexDir, { recursive: true });
263 const finalForbidden = await formatHtml(forbiddenHtml, collectionIndexPath);
264 await writeIfChanged(collectionIndexPath, finalForbidden);
265 continue;
266 }
267
268 const visibleEntries = collection.entries.filter((entry) => !entry.isUnlisted);
269 const collectionForIndex = { ...collection, entries: visibleEntries };
270
271 const override = collectionIndexTargets.get(collection.slug || collection.id);
272 if (override) {
273 const routeRelative = collection.route
274 ? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
275 : collection.slug || collection.id;
276 const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
277 const entryForIndex: Entry = {
278 ...override.entry,
279 route: collection.route ?? `/${collection.slug || collection.id}/`,
280 };
281 const wrote = await renderEntryTo(entryForIndex, override.target, collectionIndexDir);
282 if (wrote) renderedPages += 1;
283 continue;
284 }
285
286 const collectionIndexLayoutName = await pickIndexLayout(
287 `index.collection.${collection.slug || collection.id}`,
288 [
289 customIndex?.collection?.[collection.slug || collection.id] ?? "",
290 customIndex?.collections ?? "",
291 templatesDefault.index,
292 ]
293 );
294 const routeRelative = collection.route
295 ? collection.route.replace(/^\/+/, "").replace(/\/+$/, "")
296 : collection.slug || collection.id;
297 const collectionIndexDir = routeRelative ? path.join(outputDir, routeRelative) : outputDir;
298 const collectionIndexPath = path.join(collectionIndexDir, "index.html");
299
300 const ctx = {
301 ...renderBaseContext,
302 layoutName: collectionIndexLayoutName,
303 entry: { name: collection.name, route: collection.route },
304 collection: collectionForIndex,
305 entries: visibleEntries,
306 };
307 const colHtml = templateRenderer.render(ctx);
308 await mkdir(collectionIndexDir, { recursive: true });
309 const finalColHtml = await formatHtml(colHtml, collectionIndexPath);
310 await writeIfChanged(collectionIndexPath, finalColHtml);
311 }
312 }
313
314 // Static 404 (always)
315 {
316 const notFoundContext = {
317 ...renderBaseContext,
318 layoutName: notFoundLayoutName,
319 entry: { name: "Not found", route: "/404" },
320 };
321 const notFoundHtml = templateRenderer.render(notFoundContext);
322 const notFoundPath = path.join(outputDir, "404.html");
323 await mkdir(path.dirname(notFoundPath), { recursive: true });
324 const finalNotFoundHtml = await formatHtml(notFoundHtml, notFoundPath);
325 await writeIfChanged(notFoundPath, finalNotFoundHtml);
326 }
327
328 return { renderedPages };
329}