express-hbs 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,36 @@ hbs.express4({
79
79
  });
80
80
  ```
81
81
 
82
+ ## TypeScript
83
+
84
+ Type declarations ship with the package (`index.d.ts`), so no `@types` package
85
+ is needed:
86
+
87
+ ```ts
88
+ import hbs = require('express-hbs');
89
+
90
+ app.engine('hbs', hbs.express4({ partialsDir: __dirname + '/views/partials' }));
91
+ ```
92
+
93
+ Supporting types such as `hbs.Options` and `hbs.AsyncHelperCallback` are
94
+ available on the `hbs` namespace.
95
+
96
+ Async helpers come in two forms, `(context, cb)` and `(context, options, cb)`.
97
+ TypeScript infers parameter types for the first form only. For the second, add
98
+ `satisfies hbs.AsyncHelperWithOptions` so the parameters are inferred:
99
+
100
+ ```ts
101
+ hbs.registerAsyncHelper('lookup', function (context, options, cb) {
102
+ cb(options.hash.fallback ?? context);
103
+ } satisfies hbs.AsyncHelperWithOptions);
104
+ ```
105
+
106
+ Promise helpers infer `context` and `options` without `satisfies`:
107
+
108
+ ```ts
109
+ hbs.registerPromiseHelper('lookup', async (context, options) => options.hash.fallback ?? context);
110
+ ```
111
+
82
112
  ## Syntax
83
113
 
84
114
  To mark where a layout should insert page content:
@@ -190,6 +220,41 @@ in markup
190
220
  {{{readFile 'tos.txt'}}}
191
221
  ```
192
222
 
223
+ The value returned by an async helper is ignored; only `cb` delivers output.
224
+
225
+ ### Promise helpers
226
+
227
+ `registerPromiseHelper` renders whatever the helper returns, waiting for it when
228
+ it is a promise, so `async` functions work directly. The helper is called like a
229
+ synchronous helper: template params first, then `options`.
230
+
231
+ ```js
232
+ hbs.registerPromiseHelper('readFile', async function(filename, options) {
233
+ var content = await fs.promises.readFile(path.join(viewsDir, filename), 'utf8');
234
+ return new hbs.SafeString(content);
235
+ });
236
+ ```
237
+
238
+ in markup
239
+ ```
240
+ {{readFile 'tos.txt'}}
241
+ ```
242
+
243
+ - A rejected promise or a thrown error fails the render and is passed to the
244
+ render callback.
245
+ - `null` and `undefined` render as an empty string.
246
+ - Strings are escaped in `{{ }}` and not in `{{{ }}}`; return `hbs.SafeString`
247
+ to skip escaping.
248
+ - The result is only available as output. Like async helpers, a promise helper
249
+ returns a placeholder that is replaced after rendering, so subexpressions and
250
+ block conditions such as `{{#if (enabled)}}` or `{{upper (readFile 'a')}}`
251
+ see the placeholder string, not the resolved value. Do that work inside the
252
+ helper instead.
253
+
254
+ Use `registerAsyncHelper` for callback-style helpers. It does not wait for a
255
+ returned promise, because an `async function (context, cb)` that calls `cb`
256
+ later returns a promise that resolves to `undefined` first.
257
+
193
258
 
194
259
  ## i18n support
195
260
 
package/index.d.ts ADDED
@@ -0,0 +1,146 @@
1
+ import Handlebars = require('handlebars');
2
+
3
+ declare namespace hbs {
4
+ type HandlebarsEnvironment = typeof Handlebars;
5
+
6
+ /** Compiled Handlebars template, tagged with the file it came from when known. */
7
+ type CompiledTemplate = Handlebars.TemplateDelegate & { __filename?: string };
8
+
9
+ /** Minimal shape of the `i18n` module used to register the `__` and `__n` helpers. */
10
+ interface I18n {
11
+ __(...args: any[]): string;
12
+ __n(...args: any[]): string;
13
+ }
14
+
15
+ interface Options {
16
+ /** Path to partial templates; one directory or several. */
17
+ partialsDir?: string | string[];
18
+ /** Absolute path to a directory that layouts must reside in. */
19
+ restrictLayoutsTo?: string;
20
+ /** Name of the block helper. Defaults to `block`. */
21
+ blockHelperName?: string;
22
+ /** Name of the content helper. Defaults to `contentFor`. */
23
+ contentHelperName?: string;
24
+ /** Absolute path to the default layout template. */
25
+ defaultLayout?: string;
26
+ /** Extension for templates and partials. Defaults to `.hbs`. */
27
+ extname?: string;
28
+ /** Handlebars module to use instead of the bundled instance. */
29
+ handlebars?: HandlebarsEnvironment;
30
+ /** i18n object; registers the `__` and `__n` helpers. */
31
+ i18n?: I18n;
32
+ /** Path to layout templates. */
33
+ layoutsDir?: string;
34
+ /** Views directory used when not rendering through Express. */
35
+ viewsDir?: string | string[];
36
+ /** Options passed to every compiled template call. */
37
+ templateOptions?: Handlebars.RuntimeOptions;
38
+ /** Pretty-print rendered HTML with js-beautify. */
39
+ beautify?: boolean;
40
+ /** Override template compilation. */
41
+ onCompile?: (exhbs: ExpressHbs, source: string, filename?: string) => CompiledTemplate;
42
+ }
43
+
44
+ /** Locals passed to `res.render()` / the engine function. */
45
+ interface RenderOptions {
46
+ settings?: { views?: string | string[]; [key: string]: any };
47
+ cache?: boolean;
48
+ /**
49
+ * Layout to render with, or a falsy value to render without any layout.
50
+ *
51
+ * Passing user-controlled data here can read arbitrary files unless
52
+ * `restrictLayoutsTo` is set.
53
+ */
54
+ layout?: string | null | false | 0;
55
+ [key: string]: any;
56
+ }
57
+
58
+ type RenderCallback = (err: any, html?: string) => void;
59
+
60
+ /** Express-compatible view engine function. */
61
+ interface Engine {
62
+ (filename: string, options: RenderOptions, cb: RenderCallback): void;
63
+ (filename: string, source: string | null, options: RenderOptions, cb: RenderCallback): void;
64
+ }
65
+
66
+ type AsyncHelperCallback = (result: any) => void;
67
+
68
+ type AsyncHelper = (this: any, context: any, cb: AsyncHelperCallback) => void;
69
+
70
+ type AsyncHelperWithOptions = (
71
+ this: any,
72
+ context: any,
73
+ options: Handlebars.HelperOptions,
74
+ cb: AsyncHelperCallback,
75
+ ) => void;
76
+
77
+ /**
78
+ * Promise helper. Handlebars passes `(...params, options)`; this type covers
79
+ * the common one-param form so TypeScript can infer every parameter.
80
+ */
81
+ type PromiseHelper = (this: any, context: any, options: Handlebars.HelperOptions) => unknown;
82
+
83
+ interface ExpressHbs {
84
+ handlebars: HandlebarsEnvironment;
85
+ SafeString: typeof Handlebars.SafeString;
86
+ Utils: typeof Handlebars.Utils;
87
+
88
+ express3(options?: Options): Engine;
89
+ express4(options?: Options): Engine;
90
+
91
+ registerHelper(name: string, fn: Handlebars.HelperDelegate): void;
92
+ registerHelper(helpers: Handlebars.HelperDeclareSpec): void;
93
+ /**
94
+ * Register a helper that delivers its result later via `cb(result)`. The
95
+ * helper receives `options` only when it declares three parameters.
96
+ *
97
+ * TypeScript infers parameter types for the `(context, cb)` form. For the
98
+ * `(context, options, cb)` form, add `satisfies hbs.AsyncHelperWithOptions`
99
+ * to the function so its parameters are inferred.
100
+ *
101
+ * @example
102
+ * hbs.registerAsyncHelper('readFile', function (filename, cb) {
103
+ * fs.readFile(filename, 'utf8', (err, content) => cb(content));
104
+ * });
105
+ *
106
+ * hbs.registerAsyncHelper('lookup', function (context, options, cb) {
107
+ * cb(options.hash.fallback ?? context);
108
+ * } satisfies hbs.AsyncHelperWithOptions);
109
+ */
110
+ registerAsyncHelper(name: string, fn: AsyncHelper): void;
111
+ registerAsyncHelper(name: string, fn: AsyncHelperWithOptions): void;
112
+ /**
113
+ * Register a helper whose return value, or the value its promise resolves
114
+ * to, is rendered. A rejection or thrown error fails the render, and
115
+ * `null`/`undefined` render as an empty string. It is called like a sync
116
+ * helper, with the template params followed by `options`.
117
+ *
118
+ * The result is only usable as output: subexpressions and block conditions
119
+ * such as `{{#if (helper)}}` see a placeholder string, not the resolved value.
120
+ *
121
+ * @example
122
+ * hbs.registerPromiseHelper('readFile', async function (filename, options) {
123
+ * return new hbs.SafeString(await fs.promises.readFile(filename, 'utf8'));
124
+ * });
125
+ */
126
+ registerPromiseHelper(name: string, fn: PromiseHelper): void;
127
+ registerPromiseHelper(name: string, fn: (this: any, ...args: any[]) => unknown): void;
128
+ registerPartial(name: string, source: string, filename?: string): void;
129
+ compile(source: string, filename?: string): CompiledTemplate;
130
+
131
+ getTemplateOptions(): Handlebars.RuntimeOptions;
132
+ updateTemplateOptions(templateOptions: Handlebars.RuntimeOptions): void;
133
+ getLocalTemplateOptions(locals: object): Handlebars.RuntimeOptions;
134
+ updateLocalTemplateOptions<T extends Handlebars.RuntimeOptions | undefined>(
135
+ locals: object,
136
+ localTemplateOptions: T,
137
+ ): T;
138
+
139
+ /** Creates an isolated instance with its own cache and Handlebars environment. */
140
+ create(): ExpressHbs;
141
+ }
142
+ }
143
+
144
+ declare const hbs: hbs.ExpressHbs;
145
+
146
+ export = hbs;
package/lib/hbs.js CHANGED
@@ -348,7 +348,7 @@ ExpressHbs.prototype.compile = function (source, filename) {
348
348
  if (typeof source !== 'string') {
349
349
  throw new Error('registerPartial must be a string for empty comment workaround');
350
350
  }
351
- if (source.indexOf('}}') === source.length - 2) {
351
+ if (source.length >= 2 && source.indexOf('}}') === source.length - 2) {
352
352
  source += ' ';
353
353
  }
354
354
 
@@ -375,15 +375,20 @@ ExpressHbs.prototype.compile = function (source, filename) {
375
375
  * @param {String} name The name of the partial as used in a template.
376
376
  * @param {String} fn The `function(options, cb)`
377
377
  */
378
+ function getResolverCache(self, name, context, options) {
379
+ var resolverCache =
380
+ self.resolverCache ||
381
+ _.get(context, 'data.root.resolverCache') ||
382
+ _.get(options, 'data.root.resolverCache');
383
+ if (!resolverCache) {
384
+ throw new Error('Could not find resolver cache in async helper ' + name + '.');
385
+ }
386
+ return resolverCache;
387
+ }
388
+
378
389
  ExpressHbs.prototype.registerAsyncHelper = function (name, fn) {
379
390
  this.handlebars.registerHelper(name, function (context, options) {
380
- var resolverCache =
381
- this.resolverCache ||
382
- _.get(context, 'data.root.resolverCache') ||
383
- _.get(options, 'data.root.resolverCache');
384
- if (!resolverCache) {
385
- throw new Error('Could not find resolver cache in async helper ' + name + '.');
386
- }
391
+ var resolverCache = getResolverCache(this, name, context, options);
387
392
  if (options && fn.length > 2) {
388
393
  var resolveFunc = function (arr, cb) {
389
394
  return fn.call(this, arr[0], arr[1], cb);
@@ -395,6 +400,28 @@ ExpressHbs.prototype.registerAsyncHelper = function (name, fn) {
395
400
  });
396
401
  };
397
402
 
403
+ /**
404
+ * Registers a helper that returns its result, or a promise for it.
405
+ *
406
+ * Unlike `registerAsyncHelper`, the returned value is used as the output, so
407
+ * `async` functions work directly. A rejection or thrown error fails the render.
408
+ *
409
+ * @param {String} name The name of the helper as used in a template.
410
+ * @param {Function} fn Called like a sync helper, `function(...params, options)`, and
411
+ * returns a value or a promise for one.
412
+ */
413
+ ExpressHbs.prototype.registerPromiseHelper = function (name, fn) {
414
+ this.handlebars.registerHelper(name, function () {
415
+ var self = this;
416
+ var args = arguments;
417
+ var options = args[args.length - 1];
418
+ var resolverCache = getResolverCache(self, name, args[0], options);
419
+ return resolver.resolvePromise(resolverCache, function () {
420
+ return fn.apply(self, args);
421
+ });
422
+ });
423
+ };
424
+
398
425
  ExpressHbs.prototype.getTemplateOptions = function () {
399
426
  return this._options.templateOptions;
400
427
  };
@@ -459,7 +486,7 @@ ExpressHbs.prototype.___express = function ___express(filename, source, options,
459
486
  options.blockCache = {};
460
487
  options.resolverCache = {};
461
488
 
462
- this.viewsDir = options.settings.views || this.viewsDirOpt;
489
+ this.viewsDir = (options.settings && options.settings.views) || this.viewsDirOpt;
463
490
  var self = this;
464
491
 
465
492
  /**
@@ -643,10 +670,16 @@ ExpressHbs.prototype.___express = function ___express(filename, source, options,
643
670
  Object.keys(values).forEach(function (key) {
644
671
  values[key] = replaceValue(values, values[key]);
645
672
  });
646
- res = replaceValue(values, res);
647
- if (resolver.hasResolvers(res)) {
648
- return handleAsync(null, res);
673
+ var replaced = replaceValue(values, res);
674
+ // Re-check against the live cache: nested async helpers may have registered new IDs
675
+ // while resolving. Stop if a pass made no progress so unknown IDs can never loop forever.
676
+ if (
677
+ replaced !== res &&
678
+ resolver.hasResolvers(replaced, options.resolverCache, self.Utils.escapeExpression)
679
+ ) {
680
+ return handleAsync(null, replaced);
649
681
  }
682
+ res = replaced;
650
683
  cb(null, res);
651
684
  });
652
685
  }
package/lib/resolver.js CHANGED
@@ -12,8 +12,12 @@ var ID_ESCAPED_STRING = '<_';
12
12
 
13
13
  var ID_SUFFIX = '__';
14
14
 
15
+ function createId() {
16
+ return ID_PREFIX + ID_ESCAPED_STRING + generateId(ID_LENGTH) + ID_SUFFIX;
17
+ }
18
+
15
19
  function resolve(cache, fn, context) {
16
- var id = ID_PREFIX + ID_ESCAPED_STRING + generateId(ID_LENGTH) + ID_SUFFIX;
20
+ var id = createId();
17
21
  cache[id] = new Promise(function (passed, failed) {
18
22
  try {
19
23
  fn(context, function (res) {
@@ -26,6 +30,23 @@ function resolve(cache, fn, context) {
26
30
  return id;
27
31
  }
28
32
 
33
+ // Like resolve, but `fn` takes no callback: it returns the result or a thenable.
34
+ // A sync throw or a rejection fails the render. null/undefined render as an empty
35
+ // string, matching how Handlebars renders sync helpers.
36
+ function resolvePromise(cache, fn) {
37
+ var id = createId();
38
+ var promise = new Promise(function (passed) {
39
+ passed(fn());
40
+ }).then(function (res) {
41
+ return res === undefined || res === null ? '' : res;
42
+ });
43
+ // done() reports the rejection; this stops Node flagging it as unhandled when the
44
+ // render fails before done() attaches its handlers.
45
+ promise.catch(function () {});
46
+ cache[id] = promise;
47
+ return id;
48
+ }
49
+
29
50
  // cache is an object where the keys are cache keys and values are promises. See above.
30
51
  function done(cache, callback) {
31
52
  return Promise.all(Object.values(cache))
@@ -38,22 +59,35 @@ function done(cache, callback) {
38
59
  callback(null, resolvedCache);
39
60
  })
40
61
  .catch(function (error) {
62
+ // Callers use `if (err)`, so a falsy reason (e.g. `Promise.reject()`) would
63
+ // otherwise be treated as success with no values.
64
+ if (!error) {
65
+ error = new Error('Async helper failed with ' + String(error));
66
+ }
41
67
  callback(error);
42
68
  });
43
69
  }
44
70
 
45
- function hasResolvers(text) {
71
+ function hasResolvers(text, cache, escape) {
46
72
  // NOTE: We specifically search the text for the ID_PREFIX **NOT** including the escapable character
47
73
  // This is because that character can be escaped in the text, and lead us to not finding unresolved
48
74
  // async helper outputs.
49
- if (text.search(ID_PREFIX) >= 0) {
75
+ if (text.indexOf(ID_PREFIX) < 0) {
76
+ return false;
77
+ }
78
+ if (!cache) {
50
79
  return true;
51
80
  }
52
- return false;
81
+ // Only report IDs that were actually generated by an async helper. The prefix can also
82
+ // appear in user-supplied data, which must never be treated as an unresolved helper.
83
+ return Object.keys(cache).some(function (id) {
84
+ return text.indexOf(id) >= 0 || (escape && text.indexOf(escape(id)) >= 0);
85
+ });
53
86
  }
54
87
 
55
88
  module.exports = {
56
89
  done: done,
57
90
  hasResolvers: hasResolvers,
58
91
  resolve: resolve,
92
+ resolvePromise: resolvePromise,
59
93
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "express-hbs",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Express handlebars template engine complete with multiple layouts, partials and blocks.",
5
5
  "keywords": [
6
6
  "express4",
@@ -10,8 +10,10 @@
10
10
  "partials"
11
11
  ],
12
12
  "main": "index.js",
13
+ "types": "index.d.ts",
13
14
  "files": [
14
15
  "index.js",
16
+ "index.d.ts",
15
17
  "lib"
16
18
  ],
17
19
  "engines": {
@@ -28,7 +30,9 @@
28
30
  "author": "Mario Gutierrez <mario@barc.com>",
29
31
  "license": "MIT",
30
32
  "devDependencies": {
31
- "@tryghost/pro-ship": "2.0.1",
33
+ "@tryghost/pro-ship": "2.0.4",
34
+ "@types/express": "5.0.6",
35
+ "@types/node": "26.6.6",
32
36
  "cookie-parser": "1.4.7",
33
37
  "express": "5.3.0",
34
38
  "i18n": "0.15.4",
@@ -37,7 +41,8 @@
37
41
  "oxfmt": "0.76.0",
38
42
  "oxlint": "1.91.0",
39
43
  "rewire": "9.0.1",
40
- "supertest": "7.3.2"
44
+ "supertest": "7.3.2",
45
+ "typescript": "7.0.2"
41
46
  },
42
47
  "dependencies": {
43
48
  "handlebars": "^4.7.10",
@@ -48,7 +53,8 @@
48
53
  "js-beautify": "^2.0.0"
49
54
  },
50
55
  "scripts": {
51
- "lint": "oxlint --quiet && oxfmt --check .",
56
+ "lint": "oxlint --quiet && oxfmt --check . && pnpm lint:types",
57
+ "lint:types": "tsc -p test/types",
52
58
  "lint:fix": "oxlint --fix && oxfmt .",
53
59
  "format": "oxfmt .",
54
60
  "test": "mocha",