/* * Copyright (c) 2015, Yahoo Inc. All rights reserved. * Copyrights licensed under the New BSD License. * See the accompanying LICENSE file for terms. */ import * as Handlebars from "handlebars"; import * as fs from "graceful-fs"; import * as path from "node:path"; import { promisify } from "node:util"; import { glob } from "glob"; import type { UnknownObject, HelperDelegateObject, ConfigOptions, Engine, TemplateSpecificationObject, TemplateDelegateObject, FsCache, PartialTemplateOptions, PartialsDirObject, RenderOptions, RenderViewOptions, RenderCallback, HandlebarsImport, CompiledCache, PrecompiledCache, RenameFunction, } from "../types"; const readFile = promisify(fs.readFile); // ----------------------------------------------------------------------------- const defaultConfig: ConfigOptions = { handlebars: Handlebars, extname: ".handlebars", encoding: "utf8", layoutsDir: undefined, // Default layouts directory is relative to `express settings.view` + `layouts/` partialsDir: undefined, // Default partials directory is relative to `express settings.view` + `partials/` defaultLayout: "main", helpers: undefined, compilerOptions: undefined, runtimeOptions: undefined, }; export default class ExpressHandlebars { config: ConfigOptions; engine: Engine; encoding: BufferEncoding; layoutsDir: string; extname: string; compiled: CompiledCache; precompiled: PrecompiledCache; _fsCache: FsCache; partialsDir: string|PartialsDirObject|(string|PartialsDirObject)[]; compilerOptions: CompileOptions; runtimeOptions: RuntimeOptions; helpers: HelperDelegateObject; defaultLayout: string; handlebars: HandlebarsImport; constructor (config: ConfigOptions = {}) { // Config properties with defaults. Object.assign(this, defaultConfig, config); // save given config to override other settings. this.config = config; // Express view engine integration point. this.engine = this.renderView.bind(this); // Normalize `extname`. if (this.extname.charAt(0) !== ".") { this.extname = "." + this.extname; } // Internal caches of compiled and precompiled templates. this.compiled = {}; this.precompiled = {}; // Private internal file system cache. this._fsCache = {}; } async getPartials (options: PartialTemplateOptions = {}): Promise { if (typeof this.partialsDir === "undefined") { return {}; } const partialsDirs = Array.isArray(this.partialsDir) ? this.partialsDir : [this.partialsDir]; const dirs = await Promise.all(partialsDirs.map(async dir => { let dirPath: string; let dirTemplates: TemplateDelegateObject; let dirNamespace: string; let dirRename: RenameFunction; // Support `partialsDir` collection with object entries that contain a // templates promise and a namespace. if (typeof dir === "string") { dirPath = dir; } else if (typeof dir === "object") { dirTemplates = dir.templates; dirNamespace = dir.namespace; dirRename = dir.rename; dirPath = dir.dir; } // We must have some path to templates, or templates themselves. if (!dirPath && !dirTemplates) { throw new Error("A partials dir must be a string or config object"); } const templates: HandlebarsTemplateDelegate|TemplateSpecification = dirTemplates || await this.getTemplates(dirPath, options); return { templates: templates as HandlebarsTemplateDelegate|TemplateSpecification, namespace: dirNamespace, rename: dirRename, }; })); const partials: TemplateDelegateObject|TemplateSpecificationObject = {}; for (const dir of dirs) { const { templates, namespace, rename } = dir; const filePaths = Object.keys(templates); const getTemplateNameFn = typeof rename === "function" ? rename : this._getTemplateName.bind(this); for (const filePath of filePaths) { const partialName = getTemplateNameFn(filePath, namespace); partials[partialName] = templates[filePath]; } } return partials; } async getTemplate (filePath: string, options: PartialTemplateOptions = {}): Promise { filePath = path.resolve(filePath); const encoding = options.encoding || this.encoding; const cache: PrecompiledCache|CompiledCache = options.precompiled ? this.precompiled : this.compiled; const template: Promise = options.cache && cache[filePath]; if (template) { return template; } // Optimistically cache template promise to reduce file system I/O, but // remove from cache if there was a problem. try { cache[filePath] = this._getFile(filePath, { cache: options.cache, encoding }) .then((file: string) => { const compileTemplate: (file: string, options: RuntimeOptions) => TemplateSpecification|HandlebarsTemplateDelegate = (options.precompiled ? this._precompileTemplate : this._compileTemplate).bind(this); return compileTemplate(file, this.compilerOptions); }); return await cache[filePath]; } catch (err) { delete cache[filePath]; throw err; } } async getTemplates (dirPath: string, options: PartialTemplateOptions = {}): Promise { const cache = options.cache; const filePaths = await this._getDir(dirPath, { cache }); const templates = await Promise.all(filePaths.map(filePath => { return this.getTemplate(path.join(dirPath, filePath), options); })); const hash = {}; for (let i = 0; i < filePaths.length; i++) { hash[filePaths[i]] = templates[i]; } return hash; } async render (filePath: string, context: UnknownObject = {}, options: RenderOptions = {}): Promise { const encoding = options.encoding || this.encoding; const [template, partials] = await Promise.all([ this.getTemplate(filePath, { cache: options.cache, encoding }) as Promise, (options.partials || this.getPartials({ cache: options.cache, encoding })) as Promise, ]); const helpers: HelperDelegateObject = { ...this.helpers, ...options.helpers }; const runtimeOptions = { ...this.runtimeOptions, ...options.runtimeOptions }; // Add ExpressHandlebars metadata to the data channel so that it's // accessible within the templates and helpers, namespaced under: // `@exphbs.*` const data = { ...options.data, exphbs: { ...options, filePath, helpers, partials, runtimeOptions, }, }; const html = this._renderTemplate(template, context, { ...runtimeOptions, data, helpers, partials, }); return html; } async renderView (viewPath: string): Promise; async renderView (viewPath: string, options: RenderViewOptions): Promise; async renderView (viewPath: string, callback: RenderCallback): Promise; async renderView (viewPath: string, options: RenderViewOptions, callback: RenderCallback): Promise; async renderView (viewPath: string, options: RenderViewOptions|RenderCallback = {}, callback: RenderCallback|null = null): Promise { if (typeof options === "function") { callback = options; options = {}; } const context = options as UnknownObject; let promise: Promise|null = null; if (!callback) { promise = new Promise((resolve, reject) => { callback = (err, value) => { err !== null ? reject(err) : resolve(value); }; }); } // Express provides `settings.views` which is the path to the views dir that // the developer set on the Express app. When this value exists, it's used // to compute the view's name. Layouts and Partials directories are relative // to `settings.view` path let view: string; const views = options.settings && options.settings.views; const viewsPath = this._resolveViewsPath(views, viewPath); if (viewsPath) { view = this._getTemplateName(path.relative(viewsPath, viewPath)); this.partialsDir = this.config.partialsDir || path.join(viewsPath, "partials/"); this.layoutsDir = this.config.layoutsDir || path.join(viewsPath, "layouts/"); } const encoding = options.encoding || this.encoding; // Merge render-level and instance-level helpers together. const helpers = { ...this.helpers, ...options.helpers }; // Merge render-level and instance-level partials together. const partials: TemplateDelegateObject = { ...await this.getPartials({ cache: options.cache, encoding }) as TemplateDelegateObject, ...(options.partials || {}), }; // Pluck-out ExpressHandlebars-specific options and Handlebars-specific // rendering options. const renderOptions = { cache: options.cache, encoding, view, layout: "layout" in options ? options.layout : this.defaultLayout, data: options.data, helpers, partials, runtimeOptions: options.runtimeOptions, }; try { let html = await this.render(viewPath, context, renderOptions); const layoutPath = this._resolveLayoutPath(renderOptions.layout); if (layoutPath) { html = await this.render( layoutPath, { ...context, body: html }, { ...renderOptions, layout: undefined }, ); } callback(null, html); } catch (err) { callback(err); } return promise; } resetCache (filePathsOrFilter?: string | string[] | ((template: string) => boolean)) { let filePaths: string[] = []; if (typeof filePathsOrFilter === "undefined") { filePaths = Object.keys(this._fsCache); } else if (typeof filePathsOrFilter === "string") { filePaths = [filePathsOrFilter]; } else if (typeof filePathsOrFilter === "function") { filePaths = Object.keys(this._fsCache).filter(filePathsOrFilter); } else if (Array.isArray(filePathsOrFilter)) { filePaths = filePathsOrFilter; } for (const filePath of filePaths) { delete this._fsCache[filePath]; } } // -- Protected Hooks ---------------------------------------------------------- protected _compileTemplate (template: string, options: RuntimeOptions = {}): HandlebarsTemplateDelegate { return this.handlebars.compile(template.trim(), options); } protected _precompileTemplate (template: string, options: RuntimeOptions = {}): TemplateSpecification { return this.handlebars.precompile(template.trim(), options); } protected _renderTemplate (template: HandlebarsTemplateDelegate, context: UnknownObject = {}, options: RuntimeOptions = {}): string { return template(context, options).trim(); } // -- Private ------------------------------------------------------------------ private async _getDir (dirPath: string, options: PartialTemplateOptions = {}): Promise { dirPath = path.resolve(dirPath); const cache = this._fsCache; let dir = options.cache && (cache[dirPath] as Promise); if (dir) { return [...await dir]; } const pattern = "**/*" + this.extname; // Optimistically cache dir promise to reduce file system I/O, but remove // from cache if there was a problem. try { dir = cache[dirPath] = glob(pattern, { cwd: dirPath, follow: true, posix: true, }); // @ts-expect-error FIXME: not sure how to throw error in glob for test coverage if (options._throwTestError) { throw new Error("test"); } return [...await dir]; } catch (err) { delete cache[dirPath]; throw err; } } private async _getFile (filePath: string, options: PartialTemplateOptions = {}): Promise { filePath = path.resolve(filePath); const cache = this._fsCache; const encoding = options.encoding || this.encoding; const file = options.cache && (cache[filePath] as Promise); if (file) { return file; } // Optimistically cache file promise to reduce file system I/O, but remove // from cache if there was a problem. try { cache[filePath] = readFile(filePath, { encoding: encoding || "utf8" }); return await cache[filePath] as string; } catch (err) { delete cache[filePath]; throw err; } } private _getTemplateName (filePath: string, namespace: string = null): string { let name = filePath; if (name.endsWith(this.extname)) { name = name.substring(0, name.length - this.extname.length); } if (namespace) { name = namespace + "/" + name; } return name; } private _resolveViewsPath (views: string|string[], file: string): string|null { if (!Array.isArray(views)) { return views; } let lastDir = path.resolve(file); let dir = path.dirname(lastDir); const absoluteViews = views.map(v => path.resolve(v)); // find the closest parent while (dir !== lastDir) { const index = absoluteViews.indexOf(dir); if (index >= 0) { return views[index]; } lastDir = dir; dir = path.dirname(lastDir); } // cannot resolve view return null; } private _resolveLayoutPath (layoutPath: string): string|null { if (!layoutPath) { return null; } if (!path.extname(layoutPath)) { layoutPath += this.extname; } return path.resolve(this.layoutsDir || "", layoutPath); } }