express-handlebars
Version:
A Handlebars view engine for Express which doesn't suck.
430 lines (357 loc) • 13.4 kB
text/typescript
/*
* 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);
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 | false;
handlebars: HandlebarsImport;
constructor(config: ConfigOptions = {}) {
// Config properties with defaults.
this.handlebars = config.handlebars ?? Handlebars;
this.extname = config.extname ?? ".handlebars";
this.encoding = config.encoding ?? "utf8";
this.layoutsDir = config.layoutsDir; // Default layouts directory is relative to `express settings.view` + `layouts/`
this.partialsDir = config.partialsDir; // Default partials directory is relative to `express settings.view` + `partials/`
this.defaultLayout = "defaultLayout" in config ? config.defaultLayout : "main";
this.helpers = config.helpers;
this.compilerOptions = config.compilerOptions;
this.runtimeOptions = config.runtimeOptions;
// 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<TemplateSpecificationObject | TemplateDelegateObject> {
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 | undefined;
let dirTemplates: TemplateDelegateObject | undefined;
let dirNamespace: string | undefined;
let dirRename: RenameFunction | undefined;
// 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);
// @ts-expect-error handlebars types are incomplete
partials[partialName] = templates[filePath];
}
}
return partials;
}
async getTemplate(filePath: string, options: PartialTemplateOptions = {}): Promise<HandlebarsTemplateDelegate | TemplateSpecification> {
filePath = path.resolve(filePath);
const encoding = options.encoding || this.encoding;
const cache: PrecompiledCache | CompiledCache = options.precompiled ? this.precompiled : this.compiled;
const template: Promise<HandlebarsTemplateDelegate | TemplateSpecification> | undefined | false = 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<HandlebarsTemplateDelegate | TemplateSpecification> {
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: Record<string, HandlebarsTemplateDelegate | TemplateSpecification> = {};
for (let i = 0; i < filePaths.length; i++) {
hash[filePaths[i]] = templates[i];
}
return hash;
}
async render(filePath: string, context: UnknownObject = {}, options: RenderOptions = {}): Promise<string> {
const encoding = options.encoding || this.encoding;
const [template, partials] = await Promise.all([
this.getTemplate(filePath, { cache: options.cache, encoding }) as Promise<HandlebarsTemplateDelegate>,
(options.partials || this.getPartials({ cache: options.cache, encoding })) as Promise<TemplateDelegateObject>,
]);
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<string>;
async renderView(viewPath: string, options: RenderViewOptions): Promise<string>;
async renderView(viewPath: string, callback: RenderCallback): Promise<null>;
async renderView(viewPath: string, options: RenderViewOptions, callback: RenderCallback): Promise<null>;
async renderView(viewPath: string, options: RenderViewOptions | RenderCallback = {}, callback: RenderCallback | null = null): Promise<string | null> {
if (typeof options === "function") {
callback = options;
options = {};
}
const context = options as UnknownObject;
let promise: Promise<string> | null = null;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = (err, value) => {
if (err !== null) {
reject(err);
} else {
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 | undefined;
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 as Error);
}
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<string[]> {
dirPath = path.resolve(dirPath);
const cache = this._fsCache;
let dir = options.cache && (cache[dirPath] as Promise<string[]>);
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<string> {
filePath = path.resolve(filePath);
const cache = this._fsCache;
const encoding = options.encoding || this.encoding;
const file = options.cache && (cache[filePath] as Promise<string>);
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 });
return await cache[filePath] as string;
} catch (err) {
delete cache[filePath];
throw err;
}
}
private _getTemplateName(filePath: string, namespace: string | null = 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[] | undefined, file: string): string | null {
if (!Array.isArray(views)) {
return views ?? null;
}
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 | false): string | null {
if (!layoutPath) {
return null;
}
if (!path.extname(layoutPath)) {
layoutPath += this.extname;
}
return path.resolve(this.layoutsDir || "", layoutPath);
}
}