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}