import fs from "node:fs"; import path from "node:path"; export default class Template { private views: string; private globals: Record; private cache: boolean; private templates: Map; private debug: boolean; constructor() { this.views = "./views"; this.globals = {}; this.debug = false; this.cache = true; this.templates = new Map(); } /** * Enables or disables debug mode. * @param debug - If true, enables debug mode. */ setDebug(debug: boolean): void { this.debug = debug; } /** * Sets the directory for template files and preloads all templates. * @param views - The directory path for template files. */ setViews(views: string): void { this.views = views; this.readdir(views); } /** * Sets global variables to be used in all templates. * @param globals - An object containing global variables. */ setGlobals(globals: Record): void { this.globals = globals; } /** * Enables or disables the template caching mechanism. * @param cache - If true, enables caching. */ setCache(cache: boolean): void { this.cache = cache; } /** * Recursively reads the specified directory and loads all templates into memory. * @param dir - The directory to read. * @param root - The root directory for relative paths. */ private readdir(dir: string, root: string = dir): void { for(const d of fs.readdirSync(`${path.resolve()}/${dir}`)) { if(d.endsWith(".html")) { const file = path.parse(`${dir.replace(this.views, "")}/${d}`); const t_dir = file.dir.substring(1); const t_file = file.name; this.getTemplate(!t_dir ? t_file : `${t_dir}/${t_file}`); } else { this.readdir(`${dir}/${d}`, root); } } } /** * Retrieves a template from the cache or loads it from the file system if not cached. * @param tpl - The name of the template. * @returns The template code as a string. */ private getTemplate(tpl: string): string { let template: { code: string; cached: Date }; let cache = false; if(this.cache && this.templates.has(tpl)) { template = this.templates.get(tpl)!; cache = true; } else { template = { code: fs.readFileSync(`${this.views}/${tpl}.html`, "utf-8"), cached: new Date(), }; this.templates.set(tpl, template); } return [ template.code, this.debug ? `` : "", ].join(""); } /** * Renders a template with the provided data and local variables. * @param file - The name of the template file (without extension). * @param data - Data object to inject into the template. * @param locals - Local variables to be used within the template. * @returns The rendered HTML string. */ render(file: string, data: Record = {}, locals: Record = {}): string { data = { ...data, ...locals, ...this.globals }; try { const code = 'with(_data){const __html = [];' + '__html.push(`' + this.getTemplate(file) .replace(/[\t]/g, " ") .split("`") .join("\\`") .replace(/{{--(.*?)--}}/g, "") .replace(/{{(.+?)}}/g, "`, $1, `") .replace(/{!!(.+?)!!}/g, "`, this.escape($1), `") .replace(/@js/g, "`);") .replace(/@endjs/g, ";__html.push(`") .replace(/@mtime\((.*?)\)/g, "`);__html.push(this.getMtime('$1'));__html.push(`") .replace(/@include\((.*?)\)/g, (_, inc) => this.render(inc, data)) .replace(/@for\((.*?)\)$/gm, "`); for($1) { __html.push(`") .replace(/@endfor/g, "`); } __html.push(`") .replace(/@each\((.*?) as (.*?)\)/g, "`); this.forEach($1, ($2, key) => { __html.push(`") .replace(/@endeach/g, "`); }); __html.push(`") .replace(/@elseif\((.*?)\)(\)?)/g, "`); } else if ($1$2) { __html.push(`") .replace(/@if\((.*?)\)(\)?)/g, "`); if ($1$2) { __html.push(`") .replace(/@else/g, "`); } else { __html.push(`") .replace(/@endif/g, "`); } __html.push(`") + "`); return __html.join('').replace(/\\n\\s*\\n/g, '\\n'); }"; return new Function("_data", code).bind({ escape: this.escape, forEach: this.forEach, getMtime: this.getMtime, })(data); } catch(err: any) { console.log(file, (err as Error).message); return this.debug ? `${(err as Error).message} in ${file}` : ""; } } /** * Escapes a string for safe usage in HTML. * @param str - The string to escape. * @returns The escaped string. */ escape(str: string): string { return (str + "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/{/g, "{") .replace(/}/g, "}"); } /** * Iterates over an object or array and applies a callback function. * @param o - The object or array to iterate over. * @param f - The callback function. */ forEach(o: any, f: (value: any, key: string | number) => void): void { if(Array.isArray(o)) { o.forEach(f); } else if(typeof o === "object") { Object.keys(o).forEach((k) => f.call(null, o[k], k)); } else { throw new Error(`${o} is not an iterable object`); } } /** * Retrieves the last modification time of a file. * @param file - The file path to check. * @returns The last modification time in milliseconds. */ getMtime(file: string): number { try { return +`${fs.statSync(path.normalize(process.cwd() + file)).mtimeMs}`.split(".")[0]; } catch(err: any) { console.log(err); return 0; } } }