A simple, folder-driven static-site engine.
bun ssg fs
at dev 231 lines 7.7 kB view raw
1/** 2 * Handlebars template setup: registers partials/layouts and compiles the wrapper. 3 */ 4 5import { readFile, readdir, stat } from "node:fs/promises"; 6import * as path from "node:path"; 7import { fileURLToPath } from "node:url"; 8import Handlebars from "handlebars"; 9import { registerDefaultHelpers } from "./templates/helpers"; 10import type { Logger } from "../logging/logger"; 11import { runExtendTemplates } from "../plugins/runner"; 12import type { LoadedPlugin, PluginContext } from "../plugins/types"; 13 14type TemplateResolution = { 15 templatePath: string; // resolved wrapper path 16 templatesRoot: string; // root that provided the wrapper 17 render: (context: Record<string, unknown>) => string; 18}; 19 20export type TemplatesRoots = { 21 siteTemplatesRoot: string; 22 toolTemplatesRoot: string; 23 effectiveRoot: string; // chosen root (site if present, else tool) 24 source: "site" | "tool"; 25 hasSiteTemplates: boolean; 26 toolRoot: string; 27}; 28 29async function pathExists(filePath: string): Promise<boolean> { 30 try { 31 await stat(filePath); 32 return true; 33 } catch { 34 return false; 35 } 36} 37 38async function resolveTemplatePath( 39 candidates: string[], 40 logger?: Logger 41): Promise<{ templatePath: string; templatesRoot: string }> { 42 for (const candidate of candidates) { 43 if (await pathExists(candidate)) { 44 return { templatePath: candidate, templatesRoot: path.dirname(candidate) }; 45 } 46 } 47 const tried = candidates.map((c) => path.normalize(c)).join(", "); 48 logger?.error("template.notFound", { paths: tried }); 49 throw new Error(`No template found. Looked for: ${tried}`); 50} 51 52export async function resolveTemplatesRoots(options: { 53 rootDir: string; 54 templatesRoot: string; // value from config (can be relative) 55 siteTemplatesRoot?: string; // optional override for site templates root 56 logger?: Logger; 57}): Promise<TemplatesRoots> { 58 const toolRoot = path.join( 59 path.dirname(fileURLToPath(import.meta.url)), 60 "..", 61 ".." 62 ); 63 64 const siteRootValue = 65 typeof options.siteTemplatesRoot === "string" && options.siteTemplatesRoot.trim() 66 ? options.siteTemplatesRoot.trim() 67 : options.templatesRoot; 68 const siteTemplatesRoot = path.isAbsolute(siteRootValue) 69 ? siteRootValue 70 : path.join(options.rootDir, siteRootValue); 71 72 const toolTemplatesRoot = path.isAbsolute(options.templatesRoot) 73 ? options.templatesRoot 74 : path.join(toolRoot, options.templatesRoot); 75 76 const hasSiteTemplates = await pathExists(siteTemplatesRoot); 77 const source: TemplatesRoots["source"] = hasSiteTemplates ? "site" : "tool"; 78 const effectiveRoot = hasSiteTemplates ? siteTemplatesRoot : toolTemplatesRoot; 79 80 return { 81 siteTemplatesRoot, 82 toolTemplatesRoot, 83 effectiveRoot, 84 source, 85 hasSiteTemplates, 86 toolRoot, 87 }; 88} 89 90async function registerPartials(templatesRoot: string, logger?: Logger) { 91 const partialsDir = path.join(templatesRoot, "partial"); 92 93 try { 94 const entries = await readdir(partialsDir, { withFileTypes: true }); 95 for (const entry of entries) { 96 if (!entry.isFile()) continue; 97 if (path.extname(entry.name).toLowerCase() !== ".hbs") continue; 98 const name = path.parse(entry.name).name; 99 const partialPath = path.join(partialsDir, entry.name); 100 const source = await readFile(partialPath, "utf8"); 101 Handlebars.registerPartial(name, source); 102 } 103 } catch (err: any) { 104 if (err?.code !== "ENOENT") { 105 logger?.warn("template.partialsLoadFailed", { 106 partialsDir, 107 error: String(err), 108 }); 109 } 110 } 111} 112 113async function registerLayouts(templatesRoot: string, logger?: Logger) { 114 const layoutsDir = path.join(templatesRoot, "layout"); 115 116 try { 117 const entries = await readdir(layoutsDir, { withFileTypes: true }); 118 for (const entry of entries) { 119 if (!entry.isFile()) continue; 120 if (path.extname(entry.name).toLowerCase() !== ".hbs") continue; 121 const name = path.parse(entry.name).name; 122 const layoutPath = path.join(layoutsDir, entry.name); 123 const source = await readFile(layoutPath, "utf8"); 124 Handlebars.registerPartial(name, source); 125 } 126 } catch (err: any) { 127 if (err?.code !== "ENOENT") { 128 logger?.warn("template.layoutsLoadFailed", { 129 layoutsDir, 130 error: String(err), 131 }); 132 } 133 } 134} 135 136// Compile a Handlebars wrapper from site or tool templates (site-first fallback to tool). 137export async function createTemplateRenderer(options: { 138 rootDir: string; 139 defaultWrapperName: string; 140 defaultLayoutName: string; 141 templatesRoot: string; // from tool config 142 siteTemplatesRoot?: string; // optional site templates root 143 logger?: Logger; 144 plugins?: LoadedPlugin[]; 145 pluginContext?: PluginContext; 146}): Promise<TemplateResolution> { 147 const layoutPartialName = path.parse(options.defaultLayoutName).name || options.defaultLayoutName; 148 registerDefaultHelpers({ defaultLayoutName: layoutPartialName }); 149 150 const { 151 siteTemplatesRoot, 152 toolTemplatesRoot, 153 toolRoot, 154 } = await resolveTemplatesRoots({ 155 rootDir: options.rootDir, 156 templatesRoot: options.templatesRoot, 157 siteTemplatesRoot: options.siteTemplatesRoot, 158 logger: options.logger, 159 }); 160 161 // Register shared partials and layouts: tool first, then site overrides. 162 await registerLayouts(toolTemplatesRoot, options.logger); 163 await registerPartials(toolTemplatesRoot, options.logger); 164 await registerLayouts(siteTemplatesRoot, options.logger); 165 await registerPartials(siteTemplatesRoot, options.logger); 166 167 if (options.plugins && options.plugins.length && options.pluginContext) { 168 await runExtendTemplates(options.plugins, options.pluginContext); 169 } 170 171 // Resolve default wrapper (site > tool) 172 const wrapperCandidates = [ 173 path.join(siteTemplatesRoot, options.defaultWrapperName), 174 path.join(toolTemplatesRoot, options.defaultWrapperName), 175 ]; 176 177 const { templatePath, templatesRoot } = await resolveTemplatePath( 178 wrapperCandidates, 179 options.logger 180 ); 181 const selectedSource = 182 path.normalize(templatesRoot) === path.normalize(siteTemplatesRoot) 183 ? "site" 184 : "tool"; 185 const logBase = selectedSource === "site" ? options.rootDir : toolRoot; 186 const templatePathForLog = 187 path.relative(logBase, templatePath) || templatePath; 188 const templatesRootForLog = 189 path.relative(logBase, templatesRoot) || templatesRoot; 190 options.logger?.debug("template.resolved", { 191 kind: "wrapper", 192 templatePath: templatePathForLog, 193 templatesRoot: templatesRootForLog, 194 source: selectedSource, 195 }); 196 197 // Resolve default layout to confirm availability and log the source. 198 const layoutCandidates = [ 199 path.join(siteTemplatesRoot, options.defaultLayoutName), 200 path.join(toolTemplatesRoot, options.defaultLayoutName), 201 ]; 202 const layoutResolution = await resolveTemplatePath(layoutCandidates, options.logger); 203 const layoutSelectedSource = 204 path.normalize(layoutResolution.templatesRoot) === 205 path.normalize(siteTemplatesRoot) 206 ? "site" 207 : "tool"; 208 const layoutLogBase = 209 layoutSelectedSource === "site" ? options.rootDir : toolRoot; 210 const layoutPathForLog = 211 path.relative(layoutLogBase, layoutResolution.templatePath) || 212 layoutResolution.templatePath; 213 const layoutRootForLog = 214 path.relative(layoutLogBase, layoutResolution.templatesRoot) || 215 layoutResolution.templatesRoot; 216 options.logger?.debug("template.resolved", { 217 kind: "layout", 218 templatePath: layoutPathForLog, 219 templatesRoot: layoutRootForLog, 220 source: layoutSelectedSource, 221 }); 222 223 const templateSource = await readFile(templatePath, "utf8"); 224 const compiled = Handlebars.compile(templateSource); 225 226 return { 227 templatePath, 228 templatesRoot, 229 render: (context) => compiled(context), 230 }; 231}