/** * lib/template-loader.ts — Handlebars template loader for dev CLIs. * * Loads a .hbs file from a directory, registers a small set of helpers used by * SmartStack scaffolders (case conversions, join, eq), and returns a render function. * * Usage: * import { loadTemplate } from '../../../../lib/template-loader.js'; * const render = await loadTemplate('entity.cs.hbs', import.meta.url); * const rendered = render({ name: 'Employee', module: 'HR' }); */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import Handlebars from 'handlebars'; import { readText } from './fs.js'; import { toKebabCase, toCamelCase, toPascalCase, capitalize, singularize, pluralize, } from './string-utils.js'; // ─── Shared helper registration (runs once per process) ─────────────────── let helpersRegistered = false; function registerHelpers(): void { if (helpersRegistered) return; Handlebars.registerHelper('kebab', (s: unknown) => toKebabCase(String(s ?? ''))); Handlebars.registerHelper('camel', (s: unknown) => toCamelCase(String(s ?? ''))); Handlebars.registerHelper('pascal', (s: unknown) => toPascalCase(String(s ?? ''))); Handlebars.registerHelper('capitalize', (s: unknown) => capitalize(String(s ?? ''))); Handlebars.registerHelper('singular', (s: unknown) => singularize(String(s ?? ''))); Handlebars.registerHelper('plural', (s: unknown) => pluralize(String(s ?? ''))); Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); Handlebars.registerHelper('neq', (a: unknown, b: unknown) => a !== b); Handlebars.registerHelper('join', (arr: unknown, sep: unknown) => { if (!Array.isArray(arr)) return ''; return arr.join(String(sep ?? ', ')); }); Handlebars.registerHelper('includes', (arr: unknown, value: unknown) => { if (!Array.isArray(arr)) return false; return arr.includes(value); }); helpersRegistered = true; } // ─── Public API ──────────────────────────────────────────────────────────── /** * Load a Handlebars template by name from a directory. * * @param templateFile - The .hbs filename (e.g. "entity.cs.hbs") * @param dirHint - Either a directory path, a file path, or an import.meta.url. * If the hint is a URL or a file path, the loader resolves * the containing directory and looks for `templates/` * inside it. * @returns A function that takes a data object and returns the rendered string. */ export async function loadTemplate( templateFile: string, dirHint: string, ): Promise<(data: Record) => string> { registerHelpers(); const baseDir = resolveTemplatesDir(dirHint); const templatePath = path.join(baseDir, templateFile); const source = await readText(templatePath); return Handlebars.compile(source, { noEscape: true }); } /** * Synchronous variant — load and compile several templates at once. * Returns a map { templateFile → render function }. */ export async function loadTemplates( templateFiles: string[], dirHint: string, ): Promise) => string>> { const map = new Map) => string>(); for (const file of templateFiles) { map.set(file, await loadTemplate(file, dirHint)); } return map; } // ─── Internals ───────────────────────────────────────────────────────────── function resolveTemplatesDir(hint: string): string { // import.meta.url → file:// URL if (hint.startsWith('file:')) { const dir = path.dirname(fileURLToPath(hint)); return path.join(dir, 'templates'); } // Absolute path if (path.isAbsolute(hint)) { // If hint ends with .ts/.js assume it's a file → use its parent dir if (hint.endsWith('.ts') || hint.endsWith('.js') || hint.endsWith('.mjs')) { return path.join(path.dirname(hint), 'templates'); } // Otherwise assume it's a directory → append templates/ return path.join(hint, 'templates'); } // Relative path → resolve from cwd return path.resolve(process.cwd(), hint, 'templates'); }