A simple, folder-driven static-site engine.
bun ssg fs
at dev 329 lines 11 kB view raw
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}