summaryrefslogtreecommitdiff
path: root/dev/lib/handlebars
diff options
context:
space:
mode:
authorDarius Jahandarie <djahandarie@gmail.com>2023-11-09 13:30:31 +0000
committerGitHub <noreply@github.com>2023-11-09 13:30:31 +0000
commit5c5a167b4792af379cdacf633513cebf20728cd2 (patch)
tree5b6be3620a557d0b9177047003f6d742d9d2a32d /dev/lib/handlebars
parentb64f51c3b13a46af4dd7f1e43048ac19c781ca7b (diff)
parent0f4d36938fd0d844f548aa5a7f7e7842df8dfb41 (diff)
Merge pull request #307 from themoeway/modernize
Modernize codebase
Diffstat (limited to 'dev/lib/handlebars')
-rw-r--r--dev/lib/handlebars/LICENSE29
-rw-r--r--dev/lib/handlebars/README.md164
-rw-r--r--dev/lib/handlebars/__snapshots__/index.test.ts.snap91
-rw-r--r--dev/lib/handlebars/index.test.ts567
-rw-r--r--dev/lib/handlebars/index.ts33
-rw-r--r--dev/lib/handlebars/jest.config.js10
-rw-r--r--dev/lib/handlebars/kibana.jsonc5
-rw-r--r--dev/lib/handlebars/package.json6
-rwxr-xr-xdev/lib/handlebars/scripts/check_for_upstream_updates.sh45
-rwxr-xr-xdev/lib/handlebars/scripts/print_ast.js64
-rwxr-xr-xdev/lib/handlebars/scripts/update_upstream_git_hash.sh24
-rw-r--r--dev/lib/handlebars/src/__jest__/test_bench.ts207
-rw-r--r--dev/lib/handlebars/src/handlebars.ts47
-rw-r--r--dev/lib/handlebars/src/spec/.upstream_git_hash1
-rw-r--r--dev/lib/handlebars/src/spec/index.basic.test.ts481
-rw-r--r--dev/lib/handlebars/src/spec/index.blocks.test.ts366
-rw-r--r--dev/lib/handlebars/src/spec/index.builtins.test.ts676
-rw-r--r--dev/lib/handlebars/src/spec/index.compiler.test.ts86
-rw-r--r--dev/lib/handlebars/src/spec/index.data.test.ts269
-rw-r--r--dev/lib/handlebars/src/spec/index.helpers.test.ts958
-rw-r--r--dev/lib/handlebars/src/spec/index.partials.test.ts591
-rw-r--r--dev/lib/handlebars/src/spec/index.regressions.test.ts379
-rw-r--r--dev/lib/handlebars/src/spec/index.security.test.ts132
-rw-r--r--dev/lib/handlebars/src/spec/index.strict.test.ts164
-rw-r--r--dev/lib/handlebars/src/spec/index.subexpressions.test.ts214
-rw-r--r--dev/lib/handlebars/src/spec/index.utils.test.ts24
-rw-r--r--dev/lib/handlebars/src/spec/index.whitespace_control.test.ts88
-rw-r--r--dev/lib/handlebars/src/symbols.ts8
-rw-r--r--dev/lib/handlebars/src/types.ts225
-rw-r--r--dev/lib/handlebars/src/utils.ts69
-rw-r--r--dev/lib/handlebars/src/visitor.ts778
-rw-r--r--dev/lib/handlebars/tsconfig.json15
32 files changed, 6816 insertions, 0 deletions
diff --git a/dev/lib/handlebars/LICENSE b/dev/lib/handlebars/LICENSE
new file mode 100644
index 00000000..5d971a17
--- /dev/null
+++ b/dev/lib/handlebars/LICENSE
@@ -0,0 +1,29 @@
+The MIT License (MIT)
+
+Copyright (c) Elasticsearch BV
+Copyright (c) Copyright (C) 2011-2019 by Yehuda Katz
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at the following locations:
+ - https://github.com/handlebars-lang/handlebars.js
+ - https://github.com/elastic/kibana/tree/main/packages/kbn-handlebars
diff --git a/dev/lib/handlebars/README.md b/dev/lib/handlebars/README.md
new file mode 100644
index 00000000..cc151645
--- /dev/null
+++ b/dev/lib/handlebars/README.md
@@ -0,0 +1,164 @@
+# @kbn/handlebars
+
+A custom version of the handlebars package which, to improve security, does not use `eval` or `new Function`. This means that templates can't be compiled into JavaScript functions in advance and hence, rendering the templates is a lot slower.
+
+## Limitations
+
+- Only the following compile options are supported:
+ - `data`
+ - `knownHelpers`
+ - `knownHelpersOnly`
+ - `noEscape`
+ - `strict`
+ - `assumeObjects`
+ - `preventIndent`
+ - `explicitPartialContext`
+
+- Only the following runtime options are supported:
+ - `data`
+ - `helpers`
+ - `partials`
+ - `decorators` (not documented in the official Handlebars [runtime options documentation](https://handlebarsjs.com/api-reference/runtime-options.html))
+ - `blockParams` (not documented in the official Handlebars [runtime options documentation](https://handlebarsjs.com/api-reference/runtime-options.html))
+
+## Implementation differences
+
+The standard `handlebars` implementation:
+
+1. When given a template string, e.g. `Hello {{x}}`, return a "render" function which takes an "input" object, e.g. `{ x: 'World' }`.
+1. The first time the "render" function is called the following happens:
+ 1. Turn the template string into an Abstract Syntax Tree (AST).
+ 1. Convert the AST into a hyper optimized JavaScript function which takes the input object as an argument.
+ 1. Call the generate JavaScript function with the given "input" object to produce and return the final output string (`Hello World`).
+1. Subsequent calls to the "render" function will re-use the already generated JavaScript function.
+
+The custom `@kbn/handlebars` implementation:
+
+1. When given a template string, e.g. `Hello {{x}}`, return a "render" function which takes an "input" object, e.g. `{ x: 'World' }`.
+1. The first time the "render" function is called the following happens:
+ 1. Turn the template string into an Abstract Syntax Tree (AST).
+ 1. Process the AST with the given "input" object to produce and return the final output string (`Hello World`).
+1. Subsequent calls to the "render" function will re-use the already generated AST.
+
+_Note: Not parsing of the template string until the first call to the "render" function is deliberate as it mimics the original `handlebars` implementation. This means that any errors that occur due to an invalid template string will not be thrown until the first call to the "render" function._
+
+## Technical details
+
+The `handlebars` library exposes the API for both [generating the AST](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/compiler-api.md#ast) and walking it by implementing the [Visitor API](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/compiler-api.md#ast-visitor). We can leverage that to our advantage and create our own "render" function, which internally calls this API to generate the AST and then the API to walk the AST.
+
+The `@kbn/handlebars` implementation of the `Visitor` class implements all the necessary methods called by the parent `Visitor` code when instructed to walk the AST. They all start with an upppercase letter, e.g. `MustacheStatement` or `SubExpression`. We call this class `ElasticHandlebarsVisitor`.
+
+To parse the template string to an AST representation, we call `Handlebars.parse(templateString)`, which returns an AST object.
+
+The AST object contains a bunch of nodes, one for each element of the template string, all arranged in a tree-like structure. The root of the AST object is a node of type `Program`. This is a special node, which we do not need to worry about, but each of its direct children has a type named like the method which will be called when the walking algorithm reaches that node, e.g. `ContentStatement` or `BlockStatement`. These are the methods that our `Visitor` implementation implements.
+
+To instruct our `ElasticHandlebarsVisitor` class to start walking the AST object, we call the `accept()` method inherited from the parent `Visitor` class with the main AST object. The `Visitor` will walk each node in turn that is directly attached to the root `Program` node. For each node it traverses, it will call the matching method in our `ElasticHandlebarsVisitor` class.
+
+To instruct the `Visitor` code to traverse any child nodes of a given node, our implementation needs to manually call `accept(childNode)`, `acceptArray(arrayOfChildNodes)`, `acceptKey(node, childKeyName)`, or `acceptRequired(node, childKeyName)` from within any of the "node" methods, otherwise the child nodes are ignored.
+
+### State
+
+We keep state internally in the `ElasticHandlebarsVisitor` object using the following private properties:
+
+- `contexts`: An array (stack) of `context` objects. In a simple template this array will always only contain a single element: The main `context` object. In more complicated scenarios, new `context` objects will be pushed and popped to and from the `contexts` stack as needed.
+- `output`: An array containing the "rendered" output of each node (normally just one element per node). In the most simple template, this is simply joined together into a the final output string after the AST has been traversed. In more complicated templates, we use this array temporarily to collect parameters to give to helper functions (see the `getParams` function).
+
+## Testing
+
+The tests for `@kbn/handlebars` are integrated into the regular test suite of Kibana and are all jest tests. To run them all, simply do:
+
+```sh
+node scripts/jest packages/kbn-handlebars
+```
+
+By default, each test will run both the original `handlebars` code and the modified `@kbn/handlebars` code to compare if the output of the two are identical. To isolate a test run to just one or the other, you can use the following environment variables:
+
+- `EVAL=1` - Set to only run the original `handlebars` implementation that uses `eval`.
+- `AST=1` - Set to only run the modified `@kbn/handlebars` implementation that doesn't use `eval`.
+
+## Development
+
+Some of the tests have been copied from the upstream `handlebars` project and modified to fit our use-case, test-suite, and coding conventions. They are all located under the `packages/kbn-handlebars/src/spec` directory. To check if any of the copied files have received updates upstream that we might want to include in our copies, you can run the following script:
+
+```sh
+./packages/kbn-handlebars/scripts/check_for_upstream_updates.sh
+```
+
+_Note: This will look for changes in the `4.x` branch of the `handlebars.js` repo only. Changes in the `master` branch are ignored._
+
+Once all updates have been manually merged with our versions of the files, run the following script to "lock" us into the new updates:
+
+```sh
+./packages/kbn-handlebars/scripts/update_upstream_git_hash.sh
+```
+
+This will update file `packages/kbn-handlebars/src/spec/.upstream_git_hash`. Make sure to commit changes to this file as well.
+
+## Debugging
+
+### Print AST
+
+To output the generated AST object structure in a somewhat readable form, use the following script:
+
+```sh
+./packages/kbn-handlebars/scripts/print_ast.js
+```
+
+Example:
+
+```sh
+./packages/kbn-handlebars/scripts/print_ast.js '{{value}}'
+```
+
+Output:
+
+```js
+{
+ type: 'Program',
+ body: [
+ {
+ type: 'MustacheStatement',
+ path: {
+ type: 'PathExpression',
+ data: false,
+ depth: 0,
+ parts: [ 'value' ],
+ original: 'value'
+ },
+ params: [],
+ hash: undefined,
+ escaped: true
+ }
+ ]
+}
+```
+
+By default certain properties will be hidden in the output.
+For more control over the output, check out the options by running the script without any arguments.
+
+### Print generated code
+
+It's possible to see the generated JavaScript code that `handlebars` create for a given template using the following command line tool:
+
+```sh
+./node_modules/handlebars/print-script <template> [options]
+```
+
+Options:
+
+- `-v`: Enable verbose mode.
+
+Example:
+
+```sh
+./node_modules/handlebars/print-script '{{value}}' -v
+```
+
+You can pretty print just the generated code using this command:
+
+```sh
+./node_modules/handlebars/print-script '{{value}}' | \
+ node -e 'process.stdin.on(`data`, c => console.log(`(${eval(`(${c})`).code})`))' | \
+ npx prettier --write --stdin-filepath template.js | \
+ npx cli-highlight -l javascript
+```
diff --git a/dev/lib/handlebars/__snapshots__/index.test.ts.snap b/dev/lib/handlebars/__snapshots__/index.test.ts.snap
new file mode 100644
index 00000000..b9a8c27e
--- /dev/null
+++ b/dev/lib/handlebars/__snapshots__/index.test.ts.snap
@@ -0,0 +1,91 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Handlebars.create 1`] = `
+HandlebarsEnvironment {
+ "AST": Object {
+ "helpers": Object {
+ "helperExpression": [Function],
+ "scopedId": [Function],
+ "simpleId": [Function],
+ },
+ },
+ "COMPILER_REVISION": 8,
+ "Compiler": [Function],
+ "Exception": [Function],
+ "HandlebarsEnvironment": [Function],
+ "JavaScriptCompiler": [Function],
+ "LAST_COMPATIBLE_COMPILER_REVISION": 7,
+ "Parser": Object {
+ "yy": Object {},
+ },
+ "REVISION_CHANGES": Object {
+ "1": "<= 1.0.rc.2",
+ "2": "== 1.0.0-rc.3",
+ "3": "== 1.0.0-rc.4",
+ "4": "== 1.x.x",
+ "5": "== 2.0.0-alpha.x",
+ "6": ">= 2.0.0-beta.1",
+ "7": ">= 4.0.0 <4.3.0",
+ "8": ">= 4.3.0",
+ },
+ "SafeString": [Function],
+ "Utils": Object {
+ "__esModule": true,
+ "appendContextPath": [Function],
+ "blockParams": [Function],
+ "createFrame": [Function],
+ "escapeExpression": [Function],
+ "extend": [Function],
+ "indexOf": [Function],
+ "isArray": [Function],
+ "isEmpty": [Function],
+ "isFunction": [Function],
+ "toString": [Function],
+ },
+ "VERSION": "4.7.7",
+ "VM": Object {
+ "__esModule": true,
+ "checkRevision": [Function],
+ "invokePartial": [Function],
+ "noop": [Function],
+ "resolvePartial": [Function],
+ "template": [Function],
+ "wrapProgram": [Function],
+ },
+ "__esModule": true,
+ "compile": [Function],
+ "compileAST": [Function],
+ "createFrame": [Function],
+ "decorators": Object {
+ "inline": [Function],
+ },
+ "escapeExpression": [Function],
+ "helpers": Object {
+ "blockHelperMissing": [Function],
+ "each": [Function],
+ "helperMissing": [Function],
+ "if": [Function],
+ "log": [Function],
+ "lookup": [Function],
+ "unless": [Function],
+ "with": [Function],
+ },
+ "log": [Function],
+ "logger": Object {
+ "level": "info",
+ "log": [Function],
+ "lookupLevel": [Function],
+ "methodMap": Array [
+ "debug",
+ "info",
+ "warn",
+ "error",
+ ],
+ },
+ "parse": [Function],
+ "parseWithoutProcessing": [Function],
+ "partials": Object {},
+ "precompile": [Function],
+ "template": [Function],
+}
+`;
diff --git a/dev/lib/handlebars/index.test.ts b/dev/lib/handlebars/index.test.ts
new file mode 100644
index 00000000..ed607db1
--- /dev/null
+++ b/dev/lib/handlebars/index.test.ts
@@ -0,0 +1,567 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+/**
+ * ABOUT THIS FILE:
+ *
+ * This file is for tests not copied from the upstream handlebars project, but
+ * tests that we feel are needed in order to fully cover our use-cases.
+ */
+
+import Handlebars from '.';
+import type { HelperOptions, TemplateDelegate } from './src/types';
+import { expectTemplate, forEachCompileFunctionName } from './src/__jest__/test_bench';
+
+it('Handlebars.create', () => {
+ expect(Handlebars.create()).toMatchSnapshot();
+});
+
+describe('Handlebars.compileAST', () => {
+ describe('compiler options', () => {
+ it('noEscape', () => {
+ expectTemplate('{{value}}').withInput({ value: '<foo>' }).toCompileTo('&lt;foo&gt;');
+
+ expectTemplate('{{value}}')
+ .withCompileOptions({ noEscape: false })
+ .withInput({ value: '<foo>' })
+ .toCompileTo('&lt;foo&gt;');
+
+ expectTemplate('{{value}}')
+ .withCompileOptions({ noEscape: true })
+ .withInput({ value: '<foo>' })
+ .toCompileTo('<foo>');
+ });
+ });
+
+ it('invalid template', () => {
+ expectTemplate('{{value').withInput({ value: 42 }).toThrow(`Parse error on line 1:
+{{value
+--^
+Expecting 'ID', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'INVALID'`);
+ });
+
+ if (!process.env.EVAL) {
+ it('reassign', () => {
+ const fn = Handlebars.compileAST;
+ expect(fn('{{value}}')({ value: 42 })).toEqual('42');
+ });
+ }
+});
+
+// Extra "helpers" tests
+describe('helpers', () => {
+ it('Only provide options.fn/inverse to block helpers', () => {
+ function toHaveProperties(...args: any[]) {
+ toHaveProperties.calls++;
+ const options = args[args.length - 1];
+ expect(options).toHaveProperty('fn');
+ expect(options).toHaveProperty('inverse');
+ return 42;
+ }
+ toHaveProperties.calls = 0;
+
+ function toNotHaveProperties(...args: any[]) {
+ toNotHaveProperties.calls++;
+ const options = args[args.length - 1];
+ expect(options).not.toHaveProperty('fn');
+ expect(options).not.toHaveProperty('inverse');
+ return 42;
+ }
+ toNotHaveProperties.calls = 0;
+
+ const nonBlockTemplates = ['{{foo}}', '{{foo 1 2}}'];
+ const blockTemplates = ['{{#foo}}42{{/foo}}', '{{#foo 1 2}}42{{/foo}}'];
+
+ for (const template of nonBlockTemplates) {
+ expectTemplate(template)
+ .withInput({
+ foo: toNotHaveProperties,
+ })
+ .toCompileTo('42');
+
+ expectTemplate(template).withHelper('foo', toNotHaveProperties).toCompileTo('42');
+ }
+
+ for (const template of blockTemplates) {
+ expectTemplate(template)
+ .withInput({
+ foo: toHaveProperties,
+ })
+ .toCompileTo('42');
+
+ expectTemplate(template).withHelper('foo', toHaveProperties).toCompileTo('42');
+ }
+
+ const factor = process.env.AST || process.env.EVAL ? 1 : 2;
+ expect(toNotHaveProperties.calls).toEqual(nonBlockTemplates.length * 2 * factor);
+ expect(toHaveProperties.calls).toEqual(blockTemplates.length * 2 * factor);
+ });
+
+ it('should pass expected "this" to helper functions (without input)', () => {
+ expectTemplate('{{hello "world" 12 true false}}')
+ .withHelper('hello', function (this: any, ...args: any[]) {
+ expect(this).toMatchInlineSnapshot(`Object {}`);
+ })
+ .toCompileTo('');
+ });
+
+ it('should pass expected "this" to helper functions (with input)', () => {
+ expectTemplate('{{hello "world" 12 true false}}')
+ .withHelper('hello', function (this: any, ...args: any[]) {
+ expect(this).toMatchInlineSnapshot(`
+ Object {
+ "people": Array [
+ Object {
+ "id": 1,
+ "name": "Alan",
+ },
+ Object {
+ "id": 2,
+ "name": "Yehuda",
+ },
+ ],
+ }
+ `);
+ })
+ .withInput({
+ people: [
+ { name: 'Alan', id: 1 },
+ { name: 'Yehuda', id: 2 },
+ ],
+ })
+ .toCompileTo('');
+ });
+
+ it('should pass expected "this" and arguments to helper functions (non-block helper)', () => {
+ expectTemplate('{{hello "world" 12 true false}}')
+ .withHelper('hello', function (this: any, ...args: any[]) {
+ expect(args).toMatchInlineSnapshot(`
+ Array [
+ "world",
+ 12,
+ true,
+ false,
+ Object {
+ "data": Object {
+ "root": Object {
+ "people": Array [
+ Object {
+ "id": 1,
+ "name": "Alan",
+ },
+ Object {
+ "id": 2,
+ "name": "Yehuda",
+ },
+ ],
+ },
+ },
+ "hash": Object {},
+ "loc": Object {
+ "end": Object {
+ "column": 31,
+ "line": 1,
+ },
+ "start": Object {
+ "column": 0,
+ "line": 1,
+ },
+ },
+ "lookupProperty": [Function],
+ "name": "hello",
+ },
+ ]
+ `);
+ })
+ .withInput({
+ people: [
+ { name: 'Alan', id: 1 },
+ { name: 'Yehuda', id: 2 },
+ ],
+ })
+ .toCompileTo('');
+ });
+
+ it('should pass expected "this" and arguments to helper functions (block helper)', () => {
+ expectTemplate('{{#hello "world" 12 true false}}{{/hello}}')
+ .withHelper('hello', function (this: any, ...args: any[]) {
+ expect(args).toMatchInlineSnapshot(`
+ Array [
+ "world",
+ 12,
+ true,
+ false,
+ Object {
+ "data": Object {
+ "root": Object {
+ "people": Array [
+ Object {
+ "id": 1,
+ "name": "Alan",
+ },
+ Object {
+ "id": 2,
+ "name": "Yehuda",
+ },
+ ],
+ },
+ },
+ "fn": [Function],
+ "hash": Object {},
+ "inverse": [Function],
+ "loc": Object {
+ "end": Object {
+ "column": 42,
+ "line": 1,
+ },
+ "start": Object {
+ "column": 0,
+ "line": 1,
+ },
+ },
+ "lookupProperty": [Function],
+ "name": "hello",
+ },
+ ]
+ `);
+ })
+ .withInput({
+ people: [
+ { name: 'Alan', id: 1 },
+ { name: 'Yehuda', id: 2 },
+ ],
+ })
+ .toCompileTo('');
+ });
+});
+
+// Extra "blocks" tests
+describe('blocks', () => {
+ describe('decorators', () => {
+ it('should only call decorator once', () => {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+ expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
+ .withHelper('helper', () => {})
+ .withDecorator('decorator', () => {
+ calls++;
+ })
+ .toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ forEachCompileFunctionName((compileName) => {
+ it(`should call decorator again if render function is called again for #${compileName}`, () => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+
+ kbnHandlebarsEnv!.registerDecorator('decorator', () => {
+ calls++;
+ });
+
+ const compile = kbnHandlebarsEnv![compileName].bind(kbnHandlebarsEnv);
+ const render = compile('{{*decorator}}');
+
+ let calls = 0;
+ expect(render()).toEqual('');
+ expect(calls).toEqual(1);
+
+ calls = 0;
+ expect(render()).toEqual('');
+ expect(calls).toEqual(1);
+
+ global.kbnHandlebarsEnv = null;
+ });
+ });
+
+ it('should pass expected options to nested decorator', () => {
+ expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}')
+ .withHelper('helper', () => {})
+ .withDecorator('decorator', function (fn, props, container, options) {
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "args": Array [
+ "bar",
+ ],
+ "data": Object {
+ "root": Object {
+ "foo": "bar",
+ },
+ },
+ "hash": Object {},
+ "loc": Object {
+ "end": Object {
+ "column": 29,
+ "line": 1,
+ },
+ "start": Object {
+ "column": 11,
+ "line": 1,
+ },
+ },
+ "name": "decorator",
+ }
+ `);
+ })
+ .withInput({ foo: 'bar' })
+ .toCompileTo('');
+ });
+
+ it('should pass expected options to root decorator with no args', () => {
+ expectTemplate('{{*decorator}}')
+ .withDecorator('decorator', function (fn, props, container, options) {
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "args": Array [],
+ "data": Object {
+ "root": Object {
+ "foo": "bar",
+ },
+ },
+ "hash": Object {},
+ "loc": Object {
+ "end": Object {
+ "column": 14,
+ "line": 1,
+ },
+ "start": Object {
+ "column": 0,
+ "line": 1,
+ },
+ },
+ "name": "decorator",
+ }
+ `);
+ })
+ .withInput({ foo: 'bar' })
+ .toCompileTo('');
+ });
+
+ it('should pass expected options to root decorator with one arg', () => {
+ expectTemplate('{{*decorator foo}}')
+ .withDecorator('decorator', function (fn, props, container, options) {
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "args": Array [
+ undefined,
+ ],
+ "data": Object {
+ "root": Object {
+ "foo": "bar",
+ },
+ },
+ "hash": Object {},
+ "loc": Object {
+ "end": Object {
+ "column": 18,
+ "line": 1,
+ },
+ "start": Object {
+ "column": 0,
+ "line": 1,
+ },
+ },
+ "name": "decorator",
+ }
+ `);
+ })
+ .withInput({ foo: 'bar' })
+ .toCompileTo('');
+ });
+
+ describe('return values', () => {
+ for (const [desc, template, result] of [
+ ['non-block', '{{*decorator}}cont{{*decorator}}ent', 'content'],
+ ['block', '{{#*decorator}}con{{/decorator}}tent', 'tent'],
+ ]) {
+ describe(desc, () => {
+ const falsy = [undefined, null, false, 0, ''];
+ const truthy = [true, 42, 'foo', {}];
+
+ // Falsy return values from decorators are simply ignored and the
+ // execution falls back to default behavior which is to render the
+ // other parts of the template.
+ for (const value of falsy) {
+ it(`falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => {
+ expectTemplate(template)
+ .withDecorator('decorator', () => value)
+ .toCompileTo(result);
+ });
+ }
+
+ // Truthy return values from decorators are expected to be functions
+ // and the program will attempt to call them. We expect an error to
+ // be thrown in this case.
+ for (const value of truthy) {
+ it(`non-falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => {
+ expectTemplate(template)
+ .withDecorator('decorator', () => value)
+ .toThrow('is not a function');
+ });
+ }
+
+ // If the decorator return value is a custom function, its return
+ // value will be the final content of the template.
+ for (const value of [...falsy, ...truthy]) {
+ it(`function returning ${typeof value}: ${JSON.stringify(value)}`, () => {
+ expectTemplate(template)
+ .withDecorator('decorator', () => () => value)
+ .toCompileTo(value as string);
+ });
+ }
+ });
+ }
+ });
+
+ describe('custom return function should be called with expected arguments and its return value should be rendered in the template', () => {
+ it('root decorator', () => {
+ expectTemplate('{{*decorator}}world')
+ .withInput({ me: 'my' })
+ .withDecorator(
+ 'decorator',
+ (fn): TemplateDelegate =>
+ (context, options) => {
+ expect(context).toMatchInlineSnapshot(`
+ Object {
+ "me": "my",
+ }
+ `);
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "decorators": Object {
+ "decorator": [Function],
+ },
+ "helpers": Object {},
+ "partials": Object {},
+ }
+ `);
+ return `hello ${context.me} ${fn()}!`;
+ }
+ )
+ .toCompileTo('hello my world!');
+ });
+
+ it('decorator nested inside of array-helper', () => {
+ expectTemplate('{{#arr}}{{*decorator}}world{{/arr}}')
+ .withInput({ arr: ['my'] })
+ .withDecorator(
+ 'decorator',
+ (fn): TemplateDelegate =>
+ (context, options) => {
+ expect(context).toMatchInlineSnapshot(`"my"`);
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "blockParams": Array [
+ "my",
+ 0,
+ ],
+ "data": Object {
+ "_parent": Object {
+ "root": Object {
+ "arr": Array [
+ "my",
+ ],
+ },
+ },
+ "first": true,
+ "index": 0,
+ "key": 0,
+ "last": true,
+ "root": Object {
+ "arr": Array [
+ "my",
+ ],
+ },
+ },
+ }
+ `);
+ return `hello ${context} ${fn()}!`;
+ }
+ )
+ .toCompileTo('hello my world!');
+ });
+
+ it('decorator nested inside of custom helper', () => {
+ expectTemplate('{{#helper}}{{*decorator}}world{{/helper}}')
+ .withHelper('helper', function (options: HelperOptions) {
+ return options.fn('my', { foo: 'bar' } as any);
+ })
+ .withDecorator(
+ 'decorator',
+ (fn): TemplateDelegate =>
+ (context, options) => {
+ expect(context).toMatchInlineSnapshot(`"my"`);
+ expect(options).toMatchInlineSnapshot(`
+ Object {
+ "foo": "bar",
+ }
+ `);
+ return `hello ${context} ${fn()}!`;
+ }
+ )
+ .toCompileTo('hello my world!');
+ });
+ });
+
+ it('should call multiple decorators in the same program body in the expected order and get the expected output', () => {
+ let decoratorCall = 0;
+ let progCall = 0;
+ expectTemplate('{{*decorator}}con{{*decorator}}tent', {
+ beforeRender() {
+ // ensure the counters are reset between EVAL/AST render calls
+ decoratorCall = 0;
+ progCall = 0;
+ },
+ })
+ .withInput({
+ decoratorCall: 0,
+ progCall: 0,
+ })
+ .withDecorator('decorator', (fn) => {
+ const decoratorCallOrder = ++decoratorCall;
+ const ret: TemplateDelegate = () => {
+ const progCallOrder = ++progCall;
+ return `(decorator: ${decoratorCallOrder}, prog: ${progCallOrder}, fn: "${fn()}")`;
+ };
+ return ret;
+ })
+ .toCompileTo('(decorator: 2, prog: 1, fn: "(decorator: 1, prog: 2, fn: "content")")');
+ });
+
+ describe('registration', () => {
+ beforeEach(() => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+ });
+
+ afterEach(() => {
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('should be able to call decorators registered using the `registerDecorator` function', () => {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ kbnHandlebarsEnv!.registerDecorator('decorator', () => {
+ calls++;
+ });
+
+ expectTemplate('{{*decorator}}').toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should not be able to call decorators unregistered using the `unregisterDecorator` function', () => {
+ let calls = 0;
+
+ kbnHandlebarsEnv!.registerDecorator('decorator', () => {
+ calls++;
+ });
+
+ kbnHandlebarsEnv!.unregisterDecorator('decorator');
+
+ expectTemplate('{{*decorator}}').toThrow('lookupProperty(...) is not a function');
+ expect(calls).toEqual(0);
+ });
+ });
+ });
+});
diff --git a/dev/lib/handlebars/index.ts b/dev/lib/handlebars/index.ts
new file mode 100644
index 00000000..16030445
--- /dev/null
+++ b/dev/lib/handlebars/index.ts
@@ -0,0 +1,33 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import { Handlebars } from './src/handlebars';
+import { allowUnsafeEval } from './src/utils';
+
+// The handlebars module uses `export =`, so it can't be re-exported using `export *`.
+// However, because of Babel, we're not allowed to use `export =` ourselves.
+// So we have to resort to using `exports default` even though eslint doesn't like it.
+//
+// eslint-disable-next-line import/no-default-export
+globalThis.Handlebars = Handlebars;
+
+/**
+ * If the `unsafe-eval` CSP is set, this string constant will be `compile`,
+ * otherwise `compileAST`.
+ *
+ * This can be used to call the more optimized `compile` function in
+ * environments that support it, or fall back to `compileAST` on environments
+ * that don't.
+ */
+globalThis.handlebarsCompileFnName = allowUnsafeEval() ? 'compile' : 'compileAST';
+
+export type {
+ CompileOptions,
+ RuntimeOptions,
+ HelperDelegate,
+ TemplateDelegate,
+ DecoratorDelegate,
+ HelperOptions,
+} from './src/types';
diff --git a/dev/lib/handlebars/jest.config.js b/dev/lib/handlebars/jest.config.js
new file mode 100644
index 00000000..feb9f905
--- /dev/null
+++ b/dev/lib/handlebars/jest.config.js
@@ -0,0 +1,10 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+module.exports = {
+ preset: '@kbn/test',
+ rootDir: '../..',
+ roots: ['<rootDir>/packages/kbn-handlebars'],
+};
diff --git a/dev/lib/handlebars/kibana.jsonc b/dev/lib/handlebars/kibana.jsonc
new file mode 100644
index 00000000..59b3c28d
--- /dev/null
+++ b/dev/lib/handlebars/kibana.jsonc
@@ -0,0 +1,5 @@
+{
+ "type": "shared-common",
+ "id": "@kbn/handlebars",
+ "owner": "@elastic/kibana-security"
+}
diff --git a/dev/lib/handlebars/package.json b/dev/lib/handlebars/package.json
new file mode 100644
index 00000000..46ca823a
--- /dev/null
+++ b/dev/lib/handlebars/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@kbn/handlebars",
+ "version": "1.0.0",
+ "private": true,
+ "license": "MIT"
+} \ No newline at end of file
diff --git a/dev/lib/handlebars/scripts/check_for_upstream_updates.sh b/dev/lib/handlebars/scripts/check_for_upstream_updates.sh
new file mode 100755
index 00000000..73f7376a
--- /dev/null
+++ b/dev/lib/handlebars/scripts/check_for_upstream_updates.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+set -e
+
+TMP=.tmp-handlebars
+HASH_FILE=packages/kbn-handlebars/src/spec/.upstream_git_hash
+
+function cleanup {
+ rm -fr $TMP
+}
+
+trap cleanup EXIT
+
+rm -fr $TMP
+mkdir $TMP
+
+echo "Cloning handlebars repo..."
+git clone -q --depth 1 https://github.com/handlebars-lang/handlebars.js.git -b 4.x $TMP
+
+echo "Looking for updates..."
+hash=$(git -C $TMP rev-parse HEAD)
+expected_hash=$(cat $HASH_FILE)
+
+if [ "$hash" = "$expected_hash" ]; then
+ echo "You're all up to date :)"
+else
+ echo
+ echo "New changes has been committed to the '4.x' branch in the upstream git repository"
+ echo
+ echo "To resolve this issue, do the following:"
+ echo
+ echo " 1. Investigate the commits in the '4.x' branch of the upstream git repository."
+ echo " If files inside the 'spec' folder has been updated, sync those updates with"
+ echo " our local versions of these files (located in"
+ echo " 'packages/kbn-handlebars/src/spec')."
+ echo
+ echo " https://github.com/handlebars-lang/handlebars.js/compare/$hash...4.x"
+ echo
+ echo " 2. Execute the following script and commit the updated '$HASH_FILE'"
+ echo " file including any changes you made to our own spec files."
+ echo
+ echo " ./packages/kbn-handlebars/scripts/update_upstream_git_hash.sh"
+ echo
+ exit 1
+fi
diff --git a/dev/lib/handlebars/scripts/print_ast.js b/dev/lib/handlebars/scripts/print_ast.js
new file mode 100755
index 00000000..b97fb5a6
--- /dev/null
+++ b/dev/lib/handlebars/scripts/print_ast.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+'use strict'; // eslint-disable-line strict
+
+const { relative } = require('path');
+const { inspect } = require('util');
+
+const { parse } = require('handlebars');
+const argv = require('minimist')(process.argv.slice(2));
+
+const DEFAULT_FILTER = 'loc,strip,openStrip,inverseStrip,closeStrip';
+
+const filter = argv['show-all'] ? [''] : (argv.filter || DEFAULT_FILTER).split(',');
+const hideEmpty = argv['hide-empty'] || false;
+const template = argv._[0];
+
+if (template === undefined) {
+ const script = relative(process.cwd(), process.argv[1]);
+ console.log(`Usage: ${script} [options] <template>`);
+ console.log();
+ console.log('Options:');
+ console.log(' --filter=... A comma separated list of keys to filter from the output.');
+ console.log(` Default: ${DEFAULT_FILTER}`);
+ console.log(' --hide-empty Do not display empty properties.');
+ console.log(' --show-all Do not filter out any properties. Equivalent to --filter="".');
+ console.log();
+ console.log('Example:');
+ console.log(` ${script} --hide-empty -- 'hello {{name}}'`);
+ console.log();
+ process.exit(1);
+}
+
+console.log(inspect(reduce(parse(template, filter)), { colors: true, depth: null }));
+
+function reduce(ast) {
+ if (Array.isArray(ast)) {
+ for (let i = 0; i < ast.length; i++) {
+ ast[i] = reduce(ast[i]);
+ }
+ } else {
+ for (const k of filter) {
+ delete ast[k];
+ }
+
+ if (hideEmpty) {
+ for (const [k, v] of Object.entries(ast)) {
+ if (v === undefined || v === null || (Array.isArray(v) && v.length === 0)) {
+ delete ast[k];
+ }
+ }
+ }
+
+ for (const [k, v] of Object.entries(ast)) {
+ if (typeof v === 'object' && v !== null) {
+ ast[k] = reduce(v);
+ }
+ }
+ }
+
+ return ast;
+}
diff --git a/dev/lib/handlebars/scripts/update_upstream_git_hash.sh b/dev/lib/handlebars/scripts/update_upstream_git_hash.sh
new file mode 100755
index 00000000..52cc39e0
--- /dev/null
+++ b/dev/lib/handlebars/scripts/update_upstream_git_hash.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+
+set -e
+
+TMP=.tmp-handlebars
+HASH_FILE=packages/kbn-handlebars/src/spec/.upstream_git_hash
+
+function cleanup {
+ rm -fr $TMP
+}
+
+trap cleanup EXIT
+
+rm -fr $TMP
+mkdir $TMP
+
+echo "Cloning handlebars repo..."
+git clone -q --depth 1 https://github.com/handlebars-lang/handlebars.js.git -b 4.x $TMP
+
+echo "Updating hash file..."
+git -C $TMP rev-parse HEAD | tr -d '\n' > $HASH_FILE
+git add $HASH_FILE
+
+echo "Done! - Don't forget to commit any changes to $HASH_FILE"
diff --git a/dev/lib/handlebars/src/__jest__/test_bench.ts b/dev/lib/handlebars/src/__jest__/test_bench.ts
new file mode 100644
index 00000000..d17f7f12
--- /dev/null
+++ b/dev/lib/handlebars/src/__jest__/test_bench.ts
@@ -0,0 +1,207 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, {
+ type CompileOptions,
+ type DecoratorDelegate,
+ type HelperDelegate,
+ type RuntimeOptions,
+} from '../..';
+import type { DecoratorsHash, HelpersHash, PartialsHash, Template } from '../types';
+
+type CompileFns = 'compile' | 'compileAST';
+const compileFns: CompileFns[] = ['compile', 'compileAST'];
+if (process.env.AST) compileFns.splice(0, 1);
+else if (process.env.EVAL) compileFns.splice(1, 1);
+
+declare global {
+ var kbnHandlebarsEnv: typeof Handlebars | null; // eslint-disable-line no-var
+}
+
+global.kbnHandlebarsEnv = null;
+
+interface TestOptions {
+ beforeEach?: Function;
+ beforeRender?: Function;
+}
+
+export function expectTemplate(template: string, options?: TestOptions) {
+ return new HandlebarsTestBench(template, options);
+}
+
+export function forEachCompileFunctionName(
+ cb: (compileName: CompileFns, index: number, array: CompileFns[]) => void
+) {
+ compileFns.forEach(cb);
+}
+
+class HandlebarsTestBench {
+ private template: string;
+ private options: TestOptions;
+ private compileOptions?: CompileOptions;
+ private runtimeOptions?: RuntimeOptions;
+ private helpers: HelpersHash = {};
+ private partials: PartialsHash = {};
+ private decorators: DecoratorsHash = {};
+ private input: any = {};
+
+ constructor(template: string, options: TestOptions = {}) {
+ this.template = template;
+ this.options = options;
+ }
+
+ withCompileOptions(compileOptions?: CompileOptions) {
+ this.compileOptions = compileOptions;
+ return this;
+ }
+
+ withRuntimeOptions(runtimeOptions?: RuntimeOptions) {
+ this.runtimeOptions = runtimeOptions;
+ return this;
+ }
+
+ withInput(input: any) {
+ this.input = input;
+ return this;
+ }
+
+ withHelper<F extends HelperDelegate>(name: string, helper: F) {
+ this.helpers[name] = helper;
+ return this;
+ }
+
+ withHelpers<F extends HelperDelegate>(helperFunctions: Record<string, F>) {
+ for (const [name, helper] of Object.entries(helperFunctions)) {
+ this.withHelper(name, helper);
+ }
+ return this;
+ }
+
+ withPartial(name: string | number, partial: Template) {
+ this.partials[name] = partial;
+ return this;
+ }
+
+ withPartials(partials: Record<string, Template>) {
+ for (const [name, partial] of Object.entries(partials)) {
+ this.withPartial(name, partial);
+ }
+ return this;
+ }
+
+ withDecorator(name: string, decoratorFunction: DecoratorDelegate) {
+ this.decorators[name] = decoratorFunction;
+ return this;
+ }
+
+ withDecorators(decoratorFunctions: Record<string, DecoratorDelegate>) {
+ for (const [name, decoratorFunction] of Object.entries(decoratorFunctions)) {
+ this.withDecorator(name, decoratorFunction);
+ }
+ return this;
+ }
+
+ toCompileTo(outputExpected: string) {
+ const { outputEval, outputAST } = this.compileAndExecute();
+ if (process.env.EVAL) {
+ expect(outputEval).toEqual(outputExpected);
+ } else if (process.env.AST) {
+ expect(outputAST).toEqual(outputExpected);
+ } else {
+ expect(outputAST).toEqual(outputExpected);
+ expect(outputAST).toEqual(outputEval);
+ }
+ }
+
+ toThrow(error?: string | RegExp | jest.Constructable | Error | undefined) {
+ if (process.env.EVAL) {
+ expect(() => {
+ this.compileAndExecuteEval();
+ }).toThrowError(error);
+ } else if (process.env.AST) {
+ expect(() => {
+ this.compileAndExecuteAST();
+ }).toThrowError(error);
+ } else {
+ expect(() => {
+ this.compileAndExecuteEval();
+ }).toThrowError(error);
+ expect(() => {
+ this.compileAndExecuteAST();
+ }).toThrowError(error);
+ }
+ }
+
+ private compileAndExecute() {
+ if (process.env.EVAL) {
+ return {
+ outputEval: this.compileAndExecuteEval(),
+ };
+ } else if (process.env.AST) {
+ return {
+ outputAST: this.compileAndExecuteAST(),
+ };
+ } else {
+ return {
+ outputEval: this.compileAndExecuteEval(),
+ outputAST: this.compileAndExecuteAST(),
+ };
+ }
+ }
+
+ private compileAndExecuteEval() {
+ const renderEval = this.compileEval();
+
+ const runtimeOptions: RuntimeOptions = {
+ helpers: this.helpers,
+ partials: this.partials,
+ decorators: this.decorators,
+ ...this.runtimeOptions,
+ };
+
+ this.execBeforeRender();
+
+ return renderEval(this.input, runtimeOptions);
+ }
+
+ private compileAndExecuteAST() {
+ const renderAST = this.compileAST();
+
+ const runtimeOptions: RuntimeOptions = {
+ helpers: this.helpers,
+ partials: this.partials,
+ decorators: this.decorators,
+ ...this.runtimeOptions,
+ };
+
+ this.execBeforeRender();
+
+ return renderAST(this.input, runtimeOptions);
+ }
+
+ private compileEval(handlebarsEnv = getHandlebarsEnv()) {
+ this.execBeforeEach();
+ return handlebarsEnv.compile(this.template, this.compileOptions);
+ }
+
+ private compileAST(handlebarsEnv = getHandlebarsEnv()) {
+ this.execBeforeEach();
+ return handlebarsEnv.compileAST(this.template, this.compileOptions);
+ }
+
+ private execBeforeRender() {
+ this.options.beforeRender?.();
+ }
+
+ private execBeforeEach() {
+ if (this.options.beforeEach) {
+ this.options.beforeEach();
+ }
+ }
+}
+
+function getHandlebarsEnv() {
+ return kbnHandlebarsEnv || Handlebars.create();
+}
diff --git a/dev/lib/handlebars/src/handlebars.ts b/dev/lib/handlebars/src/handlebars.ts
new file mode 100644
index 00000000..358d1b73
--- /dev/null
+++ b/dev/lib/handlebars/src/handlebars.ts
@@ -0,0 +1,47 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+// The handlebars module uses `export =`, so we should technically use `import Handlebars = require('handlebars')`, but Babel will not allow this:
+// https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require
+import Handlebars = require('handlebars');
+
+import type { CompileOptions, RuntimeOptions, TemplateDelegate } from './types';
+import { ElasticHandlebarsVisitor } from './visitor';
+
+const originalCreate = Handlebars.create;
+
+export { Handlebars };
+
+/**
+ * Creates an isolated Handlebars environment.
+ *
+ * Each environment has its own helpers.
+ * This is only necessary for use cases that demand distinct helpers.
+ * Most use cases can use the root Handlebars environment directly.
+ *
+ * @returns A sandboxed/scoped version of the @kbn/handlebars module
+ */
+Handlebars.create = function (): typeof Handlebars {
+ const SandboxedHandlebars = originalCreate.call(Handlebars) as typeof Handlebars;
+ // When creating new Handlebars environments, ensure the custom compileAST function is present in the new environment as well
+ SandboxedHandlebars.compileAST = Handlebars.compileAST;
+ return SandboxedHandlebars;
+};
+
+Handlebars.compileAST = function (
+ input: string | hbs.AST.Program,
+ options?: CompileOptions
+): TemplateDelegate {
+ if (input == null || (typeof input !== 'string' && input.type !== 'Program')) {
+ throw new Handlebars.Exception(
+ `You must pass a string or Handlebars AST to Handlebars.compileAST. You passed ${input}`
+ );
+ }
+
+ // If `Handlebars.compileAST` is reassigned, `this` will be undefined.
+ const visitor = new ElasticHandlebarsVisitor(this ?? Handlebars, input, options);
+
+ return (context: any, runtimeOptions?: RuntimeOptions) => visitor.render(context, runtimeOptions);
+};
diff --git a/dev/lib/handlebars/src/spec/.upstream_git_hash b/dev/lib/handlebars/src/spec/.upstream_git_hash
new file mode 100644
index 00000000..5a6b1831
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/.upstream_git_hash
@@ -0,0 +1 @@
+c65c6cce3f626e4896a9d59250f0908be695adae \ No newline at end of file
diff --git a/dev/lib/handlebars/src/spec/index.basic.test.ts b/dev/lib/handlebars/src/spec/index.basic.test.ts
new file mode 100644
index 00000000..6acf3ae9
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.basic.test.ts
@@ -0,0 +1,481 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('basic context', () => {
+ it('most basic', () => {
+ expectTemplate('{{foo}}').withInput({ foo: 'foo' }).toCompileTo('foo');
+ });
+
+ it('escaping', () => {
+ expectTemplate('\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('{{foo}}');
+ expectTemplate('content \\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content {{foo}}');
+ expectTemplate('\\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('\\food');
+ expectTemplate('content \\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content \\food');
+ expectTemplate('\\\\ {{foo}}').withInput({ foo: 'food' }).toCompileTo('\\\\ food');
+ });
+
+ it('compiling with a basic context', () => {
+ expectTemplate('Goodbye\n{{cruel}}\n{{world}}!')
+ .withInput({
+ cruel: 'cruel',
+ world: 'world',
+ })
+ .toCompileTo('Goodbye\ncruel\nworld!');
+ });
+
+ it('compiling with a string context', () => {
+ expectTemplate('{{.}}{{length}}').withInput('bye').toCompileTo('bye3');
+ });
+
+ it('compiling with an undefined context', () => {
+ expectTemplate('Goodbye\n{{cruel}}\n{{world.bar}}!')
+ .withInput(undefined)
+ .toCompileTo('Goodbye\n\n!');
+
+ expectTemplate('{{#unless foo}}Goodbye{{../test}}{{test2}}{{/unless}}')
+ .withInput(undefined)
+ .toCompileTo('Goodbye');
+ });
+
+ it('comments', () => {
+ expectTemplate('{{! Goodbye}}Goodbye\n{{cruel}}\n{{world}}!')
+ .withInput({
+ cruel: 'cruel',
+ world: 'world',
+ })
+ .toCompileTo('Goodbye\ncruel\nworld!');
+
+ expectTemplate(' {{~! comment ~}} blah').toCompileTo('blah');
+ expectTemplate(' {{~!-- long-comment --~}} blah').toCompileTo('blah');
+ expectTemplate(' {{! comment ~}} blah').toCompileTo(' blah');
+ expectTemplate(' {{!-- long-comment --~}} blah').toCompileTo(' blah');
+ expectTemplate(' {{~! comment}} blah').toCompileTo(' blah');
+ expectTemplate(' {{~!-- long-comment --}} blah').toCompileTo(' blah');
+ });
+
+ it('boolean', () => {
+ const string = '{{#goodbye}}GOODBYE {{/goodbye}}cruel {{world}}!';
+ expectTemplate(string)
+ .withInput({
+ goodbye: true,
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: false,
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('zeros', () => {
+ expectTemplate('num1: {{num1}}, num2: {{num2}}')
+ .withInput({
+ num1: 42,
+ num2: 0,
+ })
+ .toCompileTo('num1: 42, num2: 0');
+
+ expectTemplate('num: {{.}}').withInput(0).toCompileTo('num: 0');
+
+ expectTemplate('num: {{num1/num2}}')
+ .withInput({ num1: { num2: 0 } })
+ .toCompileTo('num: 0');
+ });
+
+ it('false', () => {
+ /* eslint-disable no-new-wrappers */
+ expectTemplate('val1: {{val1}}, val2: {{val2}}')
+ .withInput({
+ val1: false,
+ val2: new Boolean(false),
+ })
+ .toCompileTo('val1: false, val2: false');
+
+ expectTemplate('val: {{.}}').withInput(false).toCompileTo('val: false');
+
+ expectTemplate('val: {{val1/val2}}')
+ .withInput({ val1: { val2: false } })
+ .toCompileTo('val: false');
+
+ expectTemplate('val1: {{{val1}}}, val2: {{{val2}}}')
+ .withInput({
+ val1: false,
+ val2: new Boolean(false),
+ })
+ .toCompileTo('val1: false, val2: false');
+
+ expectTemplate('val: {{{val1/val2}}}')
+ .withInput({ val1: { val2: false } })
+ .toCompileTo('val: false');
+ /* eslint-enable */
+ });
+
+ it('should handle undefined and null', () => {
+ expectTemplate('{{awesome undefined null}}')
+ .withInput({
+ awesome(_undefined: any, _null: any, options: any) {
+ return (_undefined === undefined) + ' ' + (_null === null) + ' ' + typeof options;
+ },
+ })
+ .toCompileTo('true true object');
+
+ expectTemplate('{{undefined}}')
+ .withInput({
+ undefined() {
+ return 'undefined!';
+ },
+ })
+ .toCompileTo('undefined!');
+
+ expectTemplate('{{null}}')
+ .withInput({
+ null() {
+ return 'null!';
+ },
+ })
+ .toCompileTo('null!');
+ });
+
+ it('newlines', () => {
+ expectTemplate("Alan's\nTest").toCompileTo("Alan's\nTest");
+ expectTemplate("Alan's\rTest").toCompileTo("Alan's\rTest");
+ });
+
+ it('escaping text', () => {
+ expectTemplate("Awesome's").toCompileTo("Awesome's");
+ expectTemplate('Awesome\\').toCompileTo('Awesome\\');
+ expectTemplate('Awesome\\\\ foo').toCompileTo('Awesome\\\\ foo');
+ expectTemplate('Awesome {{foo}}').withInput({ foo: '\\' }).toCompileTo('Awesome \\');
+ expectTemplate(" ' ' ").toCompileTo(" ' ' ");
+ });
+
+ it('escaping expressions', () => {
+ expectTemplate('{{{awesome}}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>");
+
+ expectTemplate('{{&awesome}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>");
+
+ expectTemplate('{{awesome}}')
+ .withInput({ awesome: '&"\'`\\<>' })
+ .toCompileTo('&amp;&quot;&#x27;&#x60;\\&lt;&gt;');
+
+ expectTemplate('{{awesome}}')
+ .withInput({ awesome: 'Escaped, <b> looks like: &lt;b&gt;' })
+ .toCompileTo('Escaped, &lt;b&gt; looks like: &amp;lt;b&amp;gt;');
+ });
+
+ it("functions returning safestrings shouldn't be escaped", () => {
+ expectTemplate('{{awesome}}')
+ .withInput({
+ awesome() {
+ return new Handlebars.SafeString("&'\\<>");
+ },
+ })
+ .toCompileTo("&'\\<>");
+ });
+
+ it('functions', () => {
+ expectTemplate('{{awesome}}')
+ .withInput({
+ awesome() {
+ return 'Awesome';
+ },
+ })
+ .toCompileTo('Awesome');
+
+ expectTemplate('{{awesome}}')
+ .withInput({
+ awesome() {
+ return this.more;
+ },
+ more: 'More awesome',
+ })
+ .toCompileTo('More awesome');
+ });
+
+ it('functions with context argument', () => {
+ expectTemplate('{{awesome frank}}')
+ .withInput({
+ awesome(context: any) {
+ return context;
+ },
+ frank: 'Frank',
+ })
+ .toCompileTo('Frank');
+ });
+
+ it('pathed functions with context argument', () => {
+ expectTemplate('{{bar.awesome frank}}')
+ .withInput({
+ bar: {
+ awesome(context: any) {
+ return context;
+ },
+ },
+ frank: 'Frank',
+ })
+ .toCompileTo('Frank');
+ });
+
+ it('depthed functions with context argument', () => {
+ expectTemplate('{{#with frank}}{{../awesome .}}{{/with}}')
+ .withInput({
+ awesome(context: any) {
+ return context;
+ },
+ frank: 'Frank',
+ })
+ .toCompileTo('Frank');
+ });
+
+ it('block functions with context argument', () => {
+ expectTemplate('{{#awesome 1}}inner {{.}}{{/awesome}}')
+ .withInput({
+ awesome(context: any, options: any) {
+ return options.fn(context);
+ },
+ })
+ .toCompileTo('inner 1');
+ });
+
+ it('depthed block functions with context argument', () => {
+ expectTemplate('{{#with value}}{{#../awesome 1}}inner {{.}}{{/../awesome}}{{/with}}')
+ .withInput({
+ value: true,
+ awesome(context: any, options: any) {
+ return options.fn(context);
+ },
+ })
+ .toCompileTo('inner 1');
+ });
+
+ it('block functions without context argument', () => {
+ expectTemplate('{{#awesome}}inner{{/awesome}}')
+ .withInput({
+ awesome(options: any) {
+ return options.fn(this);
+ },
+ })
+ .toCompileTo('inner');
+ });
+
+ it('pathed block functions without context argument', () => {
+ expectTemplate('{{#foo.awesome}}inner{{/foo.awesome}}')
+ .withInput({
+ foo: {
+ awesome() {
+ return this;
+ },
+ },
+ })
+ .toCompileTo('inner');
+ });
+
+ it('depthed block functions without context argument', () => {
+ expectTemplate('{{#with value}}{{#../awesome}}inner{{/../awesome}}{{/with}}')
+ .withInput({
+ value: true,
+ awesome() {
+ return this;
+ },
+ })
+ .toCompileTo('inner');
+ });
+
+ it('paths with hyphens', () => {
+ expectTemplate('{{foo-bar}}').withInput({ 'foo-bar': 'baz' }).toCompileTo('baz');
+
+ expectTemplate('{{foo.foo-bar}}')
+ .withInput({ foo: { 'foo-bar': 'baz' } })
+ .toCompileTo('baz');
+
+ expectTemplate('{{foo/foo-bar}}')
+ .withInput({ foo: { 'foo-bar': 'baz' } })
+ .toCompileTo('baz');
+ });
+
+ it('nested paths', () => {
+ expectTemplate('Goodbye {{alan/expression}} world!')
+ .withInput({ alan: { expression: 'beautiful' } })
+ .toCompileTo('Goodbye beautiful world!');
+ });
+
+ it('nested paths with empty string value', () => {
+ expectTemplate('Goodbye {{alan/expression}} world!')
+ .withInput({ alan: { expression: '' } })
+ .toCompileTo('Goodbye world!');
+ });
+
+ it('literal paths', () => {
+ expectTemplate('Goodbye {{[@alan]/expression}} world!')
+ .withInput({ '@alan': { expression: 'beautiful' } })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate('Goodbye {{[foo bar]/expression}} world!')
+ .withInput({ 'foo bar': { expression: 'beautiful' } })
+ .toCompileTo('Goodbye beautiful world!');
+ });
+
+ it('literal references', () => {
+ expectTemplate('Goodbye {{[foo bar]}} world!')
+ .withInput({ 'foo bar': 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate('Goodbye {{"foo bar"}} world!')
+ .withInput({ 'foo bar': 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate("Goodbye {{'foo bar'}} world!")
+ .withInput({ 'foo bar': 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate('Goodbye {{"foo[bar"}} world!')
+ .withInput({ 'foo[bar': 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate('Goodbye {{"foo\'bar"}} world!')
+ .withInput({ "foo'bar": 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+
+ expectTemplate("Goodbye {{'foo\"bar'}} world!")
+ .withInput({ 'foo"bar': 'beautiful' })
+ .toCompileTo('Goodbye beautiful world!');
+ });
+
+ it("that current context path ({{.}}) doesn't hit helpers", () => {
+ expectTemplate('test: {{.}}')
+ .withInput(null)
+ // @ts-expect-error Setting the helper to a string instead of a function doesn't make sense normally, but here it doesn't matter
+ .withHelpers({ helper: 'awesome' })
+ .toCompileTo('test: ');
+ });
+
+ it('complex but empty paths', () => {
+ expectTemplate('{{person/name}}')
+ .withInput({ person: { name: null } })
+ .toCompileTo('');
+
+ expectTemplate('{{person/name}}').withInput({ person: {} }).toCompileTo('');
+ });
+
+ it('this keyword in paths', () => {
+ expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}')
+ .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] })
+ .toCompileTo('goodbyeGoodbyeGOODBYE');
+
+ expectTemplate('{{#hellos}}{{this/text}}{{/hellos}}')
+ .withInput({
+ hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }],
+ })
+ .toCompileTo('helloHelloHELLO');
+ });
+
+ it('this keyword nested inside path', () => {
+ expectTemplate('{{#hellos}}{{text/this/foo}}{{/hellos}}').toThrow(
+ 'Invalid path: text/this - 1:13'
+ );
+
+ expectTemplate('{{[this]}}').withInput({ this: 'bar' }).toCompileTo('bar');
+
+ expectTemplate('{{text/[this]}}')
+ .withInput({ text: { this: 'bar' } })
+ .toCompileTo('bar');
+ });
+
+ it('this keyword in helpers', () => {
+ const helpers = {
+ foo(value: any) {
+ return 'bar ' + value;
+ },
+ };
+
+ expectTemplate('{{#goodbyes}}{{foo this}}{{/goodbyes}}')
+ .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] })
+ .withHelpers(helpers)
+ .toCompileTo('bar goodbyebar Goodbyebar GOODBYE');
+
+ expectTemplate('{{#hellos}}{{foo this/text}}{{/hellos}}')
+ .withInput({
+ hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }],
+ })
+ .withHelpers(helpers)
+ .toCompileTo('bar hellobar Hellobar HELLO');
+ });
+
+ it('this keyword nested inside helpers param', () => {
+ expectTemplate('{{#hellos}}{{foo text/this/foo}}{{/hellos}}').toThrow(
+ 'Invalid path: text/this - 1:17'
+ );
+
+ expectTemplate('{{foo [this]}}')
+ .withInput({
+ foo(value: any) {
+ return value;
+ },
+ this: 'bar',
+ })
+ .toCompileTo('bar');
+
+ expectTemplate('{{foo text/[this]}}')
+ .withInput({
+ foo(value: any) {
+ return value;
+ },
+ text: { this: 'bar' },
+ })
+ .toCompileTo('bar');
+ });
+
+ it('pass string literals', () => {
+ expectTemplate('{{"foo"}}').toCompileTo('');
+ expectTemplate('{{"foo"}}').withInput({ foo: 'bar' }).toCompileTo('bar');
+
+ expectTemplate('{{#"foo"}}{{.}}{{/"foo"}}')
+ .withInput({
+ foo: ['bar', 'baz'],
+ })
+ .toCompileTo('barbaz');
+ });
+
+ it('pass number literals', () => {
+ expectTemplate('{{12}}').toCompileTo('');
+ expectTemplate('{{12}}').withInput({ '12': 'bar' }).toCompileTo('bar');
+ expectTemplate('{{12.34}}').toCompileTo('');
+ expectTemplate('{{12.34}}').withInput({ '12.34': 'bar' }).toCompileTo('bar');
+ expectTemplate('{{12.34 1}}')
+ .withInput({
+ '12.34'(arg: any) {
+ return 'bar' + arg;
+ },
+ })
+ .toCompileTo('bar1');
+ });
+
+ it('pass boolean literals', () => {
+ expectTemplate('{{true}}').toCompileTo('');
+ expectTemplate('{{true}}').withInput({ '': 'foo' }).toCompileTo('');
+ expectTemplate('{{false}}').withInput({ false: 'foo' }).toCompileTo('foo');
+ });
+
+ it('should handle literals in subexpression', () => {
+ expectTemplate('{{foo (false)}}')
+ .withInput({
+ false() {
+ return 'bar';
+ },
+ })
+ .withHelper('foo', function (arg) {
+ return arg;
+ })
+ .toCompileTo('bar');
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.blocks.test.ts b/dev/lib/handlebars/src/spec/index.blocks.test.ts
new file mode 100644
index 00000000..2d9a8707
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.blocks.test.ts
@@ -0,0 +1,366 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, { type HelperOptions } from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('blocks', () => {
+ it('array', () => {
+ const string = '{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('array without data', () => {
+ expectTemplate('{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('goodbyeGoodbyeGOODBYE goodbyeGoodbyeGOODBYE');
+ });
+
+ it('array with @index', () => {
+ expectTemplate('{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!');
+ });
+
+ it('empty block', () => {
+ const string = '{{#goodbyes}}{{/goodbyes}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('block with complex lookup', () => {
+ expectTemplate('{{#goodbyes}}{{text}} cruel {{../name}}! {{/goodbyes}}')
+ .withInput({
+ name: 'Alan',
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ })
+ .toCompileTo('goodbye cruel Alan! Goodbye cruel Alan! GOODBYE cruel Alan! ');
+ });
+
+ it('multiple blocks with complex lookup', () => {
+ expectTemplate('{{#goodbyes}}{{../name}}{{../name}}{{/goodbyes}}')
+ .withInput({
+ name: 'Alan',
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ })
+ .toCompileTo('AlanAlanAlanAlanAlanAlan');
+ });
+
+ it('block with complex lookup using nested context', () => {
+ expectTemplate('{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}').toThrow(Error);
+ });
+
+ it('block with deep nested complex lookup', () => {
+ expectTemplate(
+ '{{#outer}}Goodbye {{#inner}}cruel {{../sibling}} {{../../omg}}{{/inner}}{{/outer}}'
+ )
+ .withInput({
+ omg: 'OMG!',
+ outer: [{ sibling: 'sad', inner: [{ text: 'goodbye' }] }],
+ })
+ .toCompileTo('Goodbye cruel sad OMG!');
+ });
+
+ it('works with cached blocks', () => {
+ expectTemplate('{{#each person}}{{#with .}}{{first}} {{last}}{{/with}}{{/each}}')
+ .withCompileOptions({ data: false })
+ .withInput({
+ person: [
+ { first: 'Alan', last: 'Johnson' },
+ { first: 'Alan', last: 'Johnson' },
+ ],
+ })
+ .toCompileTo('Alan JohnsonAlan Johnson');
+ });
+
+ describe('inverted sections', () => {
+ it('inverted sections with unset value', () => {
+ expectTemplate(
+ '{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}'
+ ).toCompileTo('Right On!');
+ });
+
+ it('inverted section with false value', () => {
+ expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}')
+ .withInput({ goodbyes: false })
+ .toCompileTo('Right On!');
+ });
+
+ it('inverted section with empty set', () => {
+ expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}')
+ .withInput({ goodbyes: [] })
+ .toCompileTo('Right On!');
+ });
+
+ it('block inverted sections', () => {
+ expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people');
+ });
+
+ it('chained inverted sections', () => {
+ expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/people}}')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people');
+
+ expectTemplate(
+ '{{#people}}{{name}}{{else if nothere}}fail{{else unless nothere}}{{none}}{{/people}}'
+ )
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people');
+
+ expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people');
+ });
+
+ it('chained inverted sections with mismatch', () => {
+ expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/if}}').toThrow(Error);
+ });
+
+ it('block inverted sections with empty arrays', () => {
+ expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}')
+ .withInput({
+ none: 'No people',
+ people: [],
+ })
+ .toCompileTo('No people');
+ });
+ });
+
+ describe('standalone sections', () => {
+ it('block standalone else sections', () => {
+ expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people\n');
+
+ expectTemplate('{{#none}}\n{{.}}\n{{^}}\n{{none}}\n{{/none}}\n')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people\n');
+
+ expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people\n');
+ });
+
+ it('block standalone chained else sections', () => {
+ expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{/people}}\n')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people\n');
+
+ expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n')
+ .withInput({ none: 'No people' })
+ .toCompileTo('No people\n');
+ });
+
+ it('should handle nesting', () => {
+ expectTemplate('{{#data}}\n{{#if true}}\n{{.}}\n{{/if}}\n{{/data}}\nOK.')
+ .withInput({
+ data: [1, 3, 5],
+ })
+ .toCompileTo('1\n3\n5\nOK.');
+ });
+ });
+
+ describe('decorators', () => {
+ it('should apply mustache decorators', () => {
+ expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
+ .withHelper('helper', function (options: HelperOptions) {
+ return (options.fn as any).run;
+ })
+ .withDecorator('decorator', function (fn) {
+ (fn as any).run = 'success';
+ return fn;
+ })
+ .toCompileTo('success');
+ });
+
+ it('should apply allow undefined return', () => {
+ expectTemplate('{{#helper}}{{*decorator}}suc{{/helper}}')
+ .withHelper('helper', function (options: HelperOptions) {
+ return options.fn() + (options.fn as any).run;
+ })
+ .withDecorator('decorator', function (fn) {
+ (fn as any).run = 'cess';
+ })
+ .toCompileTo('success');
+ });
+
+ it('should apply block decorators', () => {
+ expectTemplate('{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}')
+ .withHelper('helper', function (options: HelperOptions) {
+ return (options.fn as any).run;
+ })
+ .withDecorator('decorator', function (fn, props, container, options) {
+ (fn as any).run = options.fn();
+ return fn;
+ })
+ .toCompileTo('success');
+ });
+
+ it('should support nested decorators', () => {
+ expectTemplate(
+ '{{#helper}}{{#*decorator}}{{#*nested}}suc{{/nested}}cess{{/decorator}}{{/helper}}'
+ )
+ .withHelper('helper', function (options: HelperOptions) {
+ return (options.fn as any).run;
+ })
+ .withDecorators({
+ decorator(fn, props, container, options) {
+ (fn as any).run = options.fn.nested + options.fn();
+ return fn;
+ },
+ nested(fn, props, container, options) {
+ props.nested = options.fn();
+ },
+ })
+ .toCompileTo('success');
+ });
+
+ it('should apply multiple decorators', () => {
+ expectTemplate(
+ '{{#helper}}{{#*decorator}}suc{{/decorator}}{{#*decorator}}cess{{/decorator}}{{/helper}}'
+ )
+ .withHelper('helper', function (options: HelperOptions) {
+ return (options.fn as any).run;
+ })
+ .withDecorator('decorator', function (fn, props, container, options) {
+ (fn as any).run = ((fn as any).run || '') + options.fn();
+ return fn;
+ })
+ .toCompileTo('success');
+ });
+
+ it('should access parent variables', () => {
+ expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}')
+ .withHelper('helper', function (options: HelperOptions) {
+ return (options.fn as any).run;
+ })
+ .withDecorator('decorator', function (fn, props, container, options) {
+ (fn as any).run = options.args;
+ return fn;
+ })
+ .withInput({ foo: 'success' })
+ .toCompileTo('success');
+ });
+
+ it('should work with root program', () => {
+ let run;
+ expectTemplate('{{*decorator "success"}}')
+ .withDecorator('decorator', function (fn, props, container, options) {
+ expect(options.args[0]).toEqual('success');
+ run = true;
+ return fn;
+ })
+ .withInput({ foo: 'success' })
+ .toCompileTo('');
+ expect(run).toEqual(true);
+ });
+
+ it('should fail when accessing variables from root', () => {
+ let run;
+ expectTemplate('{{*decorator foo}}')
+ .withDecorator('decorator', function (fn, props, container, options) {
+ expect(options.args[0]).toBeUndefined();
+ run = true;
+ return fn;
+ })
+ .withInput({ foo: 'fail' })
+ .toCompileTo('');
+ expect(run).toEqual(true);
+ });
+
+ describe('registration', () => {
+ beforeEach(() => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+ });
+
+ afterEach(() => {
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('unregisters', () => {
+ // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property.
+ kbnHandlebarsEnv!.decorators = {};
+
+ kbnHandlebarsEnv!.registerDecorator('foo', function () {
+ return 'fail';
+ });
+
+ expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true);
+ kbnHandlebarsEnv!.unregisterDecorator('foo');
+ expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined();
+ });
+
+ it('allows multiple globals', () => {
+ // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property.
+ kbnHandlebarsEnv!.decorators = {};
+
+ // @ts-expect-error: Expected 2 arguments, but got 1.
+ kbnHandlebarsEnv!.registerDecorator({
+ foo() {},
+ bar() {},
+ });
+
+ expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true);
+ expect(!!kbnHandlebarsEnv!.decorators.bar).toEqual(true);
+ kbnHandlebarsEnv!.unregisterDecorator('foo');
+ kbnHandlebarsEnv!.unregisterDecorator('bar');
+ expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined();
+ expect(kbnHandlebarsEnv!.decorators.bar).toBeUndefined();
+ });
+
+ it('fails with multiple and args', () => {
+ expect(() => {
+ kbnHandlebarsEnv!.registerDecorator(
+ // @ts-expect-error: Argument of type '{ world(): string; testHelper(): string; }' is not assignable to parameter of type 'string'.
+ {
+ world() {
+ return 'world!';
+ },
+ testHelper() {
+ return 'found it!';
+ },
+ },
+ {}
+ );
+ }).toThrow('Arg not supported with multiple decorators');
+ });
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.builtins.test.ts b/dev/lib/handlebars/src/spec/index.builtins.test.ts
new file mode 100644
index 00000000..c47ec29f
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.builtins.test.ts
@@ -0,0 +1,676 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+/* eslint-disable max-classes-per-file */
+
+import Handlebars from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('builtin helpers', () => {
+ describe('#if', () => {
+ it('if', () => {
+ const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: true,
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: 'dummy',
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: false,
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+
+ expectTemplate(string).withInput({ world: 'world' }).toCompileTo('cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: ['foo'],
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: [],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye: 0,
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+
+ expectTemplate('{{#if goodbye includeZero=true}}GOODBYE {{/if}}cruel {{world}}!')
+ .withInput({
+ goodbye: 0,
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+ });
+
+ it('if with function argument', () => {
+ const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbye() {
+ return true;
+ },
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye() {
+ return this.world;
+ },
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye() {
+ return false;
+ },
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbye() {
+ return this.foo;
+ },
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('should not change the depth list', () => {
+ expectTemplate('{{#with foo}}{{#if goodbye}}GOODBYE cruel {{../world}}!{{/if}}{{/with}}')
+ .withInput({
+ foo: { goodbye: true },
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel world!');
+ });
+ });
+
+ describe('#with', () => {
+ it('with', () => {
+ expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}')
+ .withInput({
+ person: {
+ first: 'Alan',
+ last: 'Johnson',
+ },
+ })
+ .toCompileTo('Alan Johnson');
+ });
+
+ it('with with function argument', () => {
+ expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}')
+ .withInput({
+ person() {
+ return {
+ first: 'Alan',
+ last: 'Johnson',
+ };
+ },
+ })
+ .toCompileTo('Alan Johnson');
+ });
+
+ it('with with else', () => {
+ expectTemplate(
+ '{{#with person}}Person is present{{else}}Person is not present{{/with}}'
+ ).toCompileTo('Person is not present');
+ });
+
+ it('with provides block parameter', () => {
+ expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}')
+ .withInput({
+ person: {
+ first: 'Alan',
+ last: 'Johnson',
+ },
+ })
+ .toCompileTo('Alan Johnson');
+ });
+
+ it('works when data is disabled', () => {
+ expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}')
+ .withInput({ person: { first: 'Alan', last: 'Johnson' } })
+ .withCompileOptions({ data: false })
+ .toCompileTo('Alan Johnson');
+ });
+ });
+
+ describe('#each', () => {
+ it('each', () => {
+ const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('each without data', () => {
+ expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .withRuntimeOptions({ data: false })
+ .withCompileOptions({ data: false })
+ .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!');
+
+ expectTemplate('{{#each .}}{{.}}{{/each}}')
+ .withInput({ goodbyes: 'cruel', world: 'world' })
+ .withRuntimeOptions({ data: false })
+ .withCompileOptions({ data: false })
+ .toCompileTo('cruelworld');
+ });
+
+ it('each without context', () => {
+ expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!')
+ .withInput(undefined)
+ .toCompileTo('cruel !');
+ });
+
+ it('each with an object and @key', () => {
+ const string = '{{#each goodbyes}}{{@key}}. {{text}}! {{/each}}cruel {{world}}!';
+
+ function Clazz(this: any) {
+ this['<b>#1</b>'] = { text: 'goodbye' };
+ this[2] = { text: 'GOODBYE' };
+ }
+ Clazz.prototype.foo = 'fail';
+ const hash = { goodbyes: new (Clazz as any)(), world: 'world' };
+
+ // Object property iteration order is undefined according to ECMA spec,
+ // so we need to check both possible orders
+ // @see http://stackoverflow.com/questions/280713/elements-order-in-a-for-in-loop
+ try {
+ expectTemplate(string)
+ .withInput(hash)
+ .toCompileTo('&lt;b&gt;#1&lt;/b&gt;. goodbye! 2. GOODBYE! cruel world!');
+ } catch (e) {
+ expectTemplate(string)
+ .withInput(hash)
+ .toCompileTo('2. GOODBYE! &lt;b&gt;#1&lt;/b&gt;. goodbye! cruel world!');
+ }
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: {},
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('each with @index', () => {
+ expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!');
+ });
+
+ it('each with nested @index', () => {
+ expectTemplate(
+ '{{#each goodbyes}}{{@index}}. {{text}}! {{#each ../goodbyes}}{{@index}} {{/each}}After {{@index}} {{/each}}{{@index}}cruel {{world}}!'
+ )
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo(
+ '0. goodbye! 0 1 2 After 0 1. Goodbye! 0 1 2 After 1 2. GOODBYE! 0 1 2 After 2 cruel world!'
+ );
+ });
+
+ it('each with block params', () => {
+ expectTemplate(
+ '{{#each goodbyes as |value index|}}{{index}}. {{value.text}}! {{#each ../goodbyes as |childValue childIndex|}} {{index}} {{childIndex}}{{/each}} After {{index}} {{/each}}{{index}}cruel {{world}}!'
+ )
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }],
+ world: 'world',
+ })
+ .toCompileTo('0. goodbye! 0 0 0 1 After 0 1. Goodbye! 1 0 1 1 After 1 cruel world!');
+ });
+
+ // TODO: This test has been added to the `4.x` branch of the handlebars.js repo along with a code-fix,
+ // but a new version of the handlebars package containing this fix has not yet been published to npm.
+ //
+ // Before enabling this code, a new version of handlebars needs to be released and the corresponding
+ // updates needs to be applied to this implementation.
+ //
+ // See: https://github.com/handlebars-lang/handlebars.js/commit/30dbf0478109ded8f12bb29832135d480c17e367
+ it.skip('each with block params and strict compilation', () => {
+ expectTemplate('{{#each goodbyes as |value index|}}{{index}}. {{value.text}}!{{/each}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }] })
+ .toCompileTo('0. goodbye!1. Goodbye!');
+ });
+
+ it('each object with @index', () => {
+ expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: {
+ a: { text: 'goodbye' },
+ b: { text: 'Goodbye' },
+ c: { text: 'GOODBYE' },
+ },
+ world: 'world',
+ })
+ .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!');
+ });
+
+ it('each with @first', () => {
+ expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('goodbye! cruel world!');
+ });
+
+ it('each with nested @first', () => {
+ expectTemplate(
+ '{{#each goodbyes}}({{#if @first}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @first}}{{text}}!{{/if}}{{/each}}{{#if @first}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!'
+ )
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('(goodbye! goodbye! goodbye!) (goodbye!) (goodbye!) cruel world!');
+ });
+
+ it('each object with @first', () => {
+ expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } },
+ world: 'world',
+ })
+ .toCompileTo('goodbye! cruel world!');
+ });
+
+ it('each with @last', () => {
+ expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE! cruel world!');
+ });
+
+ it('each object with @last', () => {
+ expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } },
+ world: 'world',
+ })
+ .toCompileTo('Goodbye! cruel world!');
+ });
+
+ it('each with nested @last', () => {
+ expectTemplate(
+ '{{#each goodbyes}}({{#if @last}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @last}}{{text}}!{{/if}}{{/each}}{{#if @last}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!'
+ )
+ .withInput({
+ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
+ world: 'world',
+ })
+ .toCompileTo('(GOODBYE!) (GOODBYE!) (GOODBYE! GOODBYE! GOODBYE!) cruel world!');
+ });
+
+ it('each with function argument', () => {
+ const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes() {
+ return [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }];
+ },
+ world: 'world',
+ })
+ .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: [],
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+
+ it('each object when last key is an empty string', () => {
+ expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!')
+ .withInput({
+ goodbyes: {
+ a: { text: 'goodbye' },
+ b: { text: 'Goodbye' },
+ '': { text: 'GOODBYE' },
+ },
+ world: 'world',
+ })
+ .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!');
+ });
+
+ it('data passed to helpers', () => {
+ expectTemplate('{{#each letters}}{{this}}{{detectDataInsideEach}}{{/each}}')
+ .withInput({ letters: ['a', 'b', 'c'] })
+ .withHelper('detectDataInsideEach', function (options) {
+ return options.data && options.data.exclaim;
+ })
+ .withRuntimeOptions({
+ data: {
+ exclaim: '!',
+ },
+ })
+ .toCompileTo('a!b!c!');
+ });
+
+ it('each on implicit context', () => {
+ expectTemplate('{{#each}}{{text}}! {{/each}}cruel world!').toThrow(Handlebars.Exception);
+ });
+
+ it('each on iterable', () => {
+ class Iterator {
+ private arr: any[];
+ private index: number = 0;
+
+ constructor(arr: any[]) {
+ this.arr = arr;
+ }
+
+ next() {
+ const value = this.arr[this.index];
+ const done = this.index === this.arr.length;
+ if (!done) {
+ this.index++;
+ }
+ return { value, done };
+ }
+ }
+
+ class Iterable {
+ private arr: any[];
+
+ constructor(arr: any[]) {
+ this.arr = arr;
+ }
+
+ [Symbol.iterator]() {
+ return new Iterator(this.arr);
+ }
+ }
+
+ const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!';
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: new Iterable([{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }]),
+ world: 'world',
+ })
+ .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!');
+
+ expectTemplate(string)
+ .withInput({
+ goodbyes: new Iterable([]),
+ world: 'world',
+ })
+ .toCompileTo('cruel world!');
+ });
+ });
+
+ describe('#log', function () {
+ /* eslint-disable no-console */
+ let $log: typeof console.log;
+ let $info: typeof console.info;
+ let $error: typeof console.error;
+
+ beforeEach(function () {
+ $log = console.log;
+ $info = console.info;
+ $error = console.error;
+
+ global.kbnHandlebarsEnv = Handlebars.create();
+ });
+
+ afterEach(function () {
+ console.log = $log;
+ console.info = $info;
+ console.error = $error;
+
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('should call logger at default level', function () {
+ let levelArg;
+ let logArg;
+ kbnHandlebarsEnv!.log = function (level, arg) {
+ levelArg = level;
+ logArg = arg;
+ };
+
+ expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(1).toEqual(levelArg);
+ expect('whee').toEqual(logArg);
+ });
+
+ it('should call logger at data level', function () {
+ let levelArg;
+ let logArg;
+ kbnHandlebarsEnv!.log = function (level, arg) {
+ levelArg = level;
+ logArg = arg;
+ };
+
+ expectTemplate('{{log blah}}')
+ .withInput({ blah: 'whee' })
+ .withRuntimeOptions({ data: { level: '03' } })
+ .withCompileOptions({ data: true })
+ .toCompileTo('');
+ expect('03').toEqual(levelArg);
+ expect('whee').toEqual(logArg);
+ });
+
+ it('should output to info', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.info = function (info) {
+ expect('whee').toEqual(info);
+ calls++;
+ if (calls === callsExpected) {
+ console.info = $info;
+ console.log = $log;
+ }
+ };
+ console.log = function (log) {
+ expect('whee').toEqual(log);
+ calls++;
+ if (calls === callsExpected) {
+ console.info = $info;
+ console.log = $log;
+ }
+ };
+
+ expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should log at data level', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.error = function (log) {
+ expect('whee').toEqual(log);
+ calls++;
+ if (calls === callsExpected) console.error = $error;
+ };
+
+ expectTemplate('{{log blah}}')
+ .withInput({ blah: 'whee' })
+ .withRuntimeOptions({ data: { level: '03' } })
+ .withCompileOptions({ data: true })
+ .toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should handle missing logger', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ // @ts-expect-error
+ console.error = undefined;
+ console.log = function (log) {
+ expect('whee').toEqual(log);
+ calls++;
+ if (calls === callsExpected) console.log = $log;
+ };
+
+ expectTemplate('{{log blah}}')
+ .withInput({ blah: 'whee' })
+ .withRuntimeOptions({ data: { level: '03' } })
+ .withCompileOptions({ data: true })
+ .toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should handle string log levels', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.error = function (log) {
+ expect('whee').toEqual(log);
+ calls++;
+ };
+
+ expectTemplate('{{log blah}}')
+ .withInput({ blah: 'whee' })
+ .withRuntimeOptions({ data: { level: 'error' } })
+ .withCompileOptions({ data: true })
+ .toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+
+ calls = 0;
+
+ expectTemplate('{{log blah}}')
+ .withInput({ blah: 'whee' })
+ .withRuntimeOptions({ data: { level: 'ERROR' } })
+ .withCompileOptions({ data: true })
+ .toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should handle hash log levels [1]', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.error = function (log) {
+ expect('whee').toEqual(log);
+ calls++;
+ };
+
+ expectTemplate('{{log blah level="error"}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should handle hash log levels [2]', function () {
+ let called = false;
+
+ console.info =
+ console.log =
+ console.error =
+ console.debug =
+ function () {
+ called = true;
+ console.info = console.log = console.error = console.debug = $log;
+ };
+
+ expectTemplate('{{log blah level="debug"}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(false).toEqual(called);
+ });
+
+ it('should pass multiple log arguments', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.info = console.log = function (log1, log2, log3) {
+ expect('whee').toEqual(log1);
+ expect('foo').toEqual(log2);
+ expect(1).toEqual(log3);
+ calls++;
+ if (calls === callsExpected) console.log = $log;
+ };
+
+ expectTemplate('{{log blah "foo" 1}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+
+ it('should pass zero log arguments', function () {
+ let calls = 0;
+ const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
+
+ console.info = console.log = function () {
+ expect(arguments.length).toEqual(0);
+ calls++;
+ if (calls === callsExpected) console.log = $log;
+ };
+
+ expectTemplate('{{log}}').withInput({ blah: 'whee' }).toCompileTo('');
+ expect(calls).toEqual(callsExpected);
+ });
+ /* eslint-enable no-console */
+ });
+
+ describe('#lookup', () => {
+ it('should lookup arbitrary content', () => {
+ expectTemplate('{{#each goodbyes}}{{lookup ../data .}}{{/each}}')
+ .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] })
+ .toCompileTo('foobar');
+ });
+
+ it('should not fail on undefined value', () => {
+ expectTemplate('{{#each goodbyes}}{{lookup ../bar .}}{{/each}}')
+ .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] })
+ .toCompileTo('');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.compiler.test.ts b/dev/lib/handlebars/src/spec/index.compiler.test.ts
new file mode 100644
index 00000000..ef5c55f2
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.compiler.test.ts
@@ -0,0 +1,86 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from '../..';
+import { forEachCompileFunctionName } from '../__jest__/test_bench';
+
+describe('compiler', () => {
+ forEachCompileFunctionName((compileName) => {
+ const compile = Handlebars[compileName].bind(Handlebars);
+
+ describe(`#${compileName}`, () => {
+ it('should fail with invalid input', () => {
+ expect(function () {
+ compile(null);
+ }).toThrow(
+ `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed null`
+ );
+
+ expect(function () {
+ compile({});
+ }).toThrow(
+ `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed [object Object]`
+ );
+ });
+
+ it('should include the location in the error (row and column)', () => {
+ try {
+ compile(' \n {{#if}}\n{{/def}}')();
+ expect(true).toEqual(false);
+ } catch (err) {
+ expect(err.message).toEqual("if doesn't match def - 2:5");
+ if (Object.getOwnPropertyDescriptor(err, 'column')!.writable) {
+ // In Safari 8, the column-property is read-only. This means that even if it is set with defineProperty,
+ // its value won't change (https://github.com/jquery/esprima/issues/1290#issuecomment-132455482)
+ // Since this was neither working in Handlebars 3 nor in 4.0.5, we only check the column for other browsers.
+ expect(err.column).toEqual(5);
+ }
+ expect(err.lineNumber).toEqual(2);
+ }
+ });
+
+ it('should include the location as enumerable property', () => {
+ try {
+ compile(' \n {{#if}}\n{{/def}}')();
+ expect(true).toEqual(false);
+ } catch (err) {
+ expect(Object.prototype.propertyIsEnumerable.call(err, 'column')).toEqual(true);
+ }
+ });
+
+ it('can utilize AST instance', () => {
+ expect(
+ compile({
+ type: 'Program',
+ body: [{ type: 'ContentStatement', value: 'Hello' }],
+ })()
+ ).toEqual('Hello');
+ });
+
+ it('can pass through an empty string', () => {
+ expect(compile('')()).toEqual('');
+ });
+
+ it('should not modify the options.data property(GH-1327)', () => {
+ // The `data` property is supposed to be a boolean, but in this test we want to ignore that
+ const options = { data: [{ a: 'foo' }, { a: 'bar' }] as unknown as boolean };
+ compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)();
+ expect(JSON.stringify(options, null, 2)).toEqual(
+ JSON.stringify({ data: [{ a: 'foo' }, { a: 'bar' }] }, null, 2)
+ );
+ });
+
+ it('should not modify the options.knownHelpers property(GH-1327)', () => {
+ const options = { knownHelpers: {} };
+ compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)();
+ expect(JSON.stringify(options, null, 2)).toEqual(
+ JSON.stringify({ knownHelpers: {} }, null, 2)
+ );
+ });
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.data.test.ts b/dev/lib/handlebars/src/spec/index.data.test.ts
new file mode 100644
index 00000000..94d3b51c
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.data.test.ts
@@ -0,0 +1,269 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, { type HelperOptions } from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('data', () => {
+ it('passing in data to a compiled function that expects data - works with helpers', () => {
+ expectTemplate('{{hello}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (this: any, options) {
+ return options.data.adjective + ' ' + this.noun;
+ })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .withInput({ noun: 'cat' })
+ .toCompileTo('happy cat');
+ });
+
+ it('data can be looked up via @foo', () => {
+ expectTemplate('{{@hello}}')
+ .withRuntimeOptions({ data: { hello: 'hello' } })
+ .toCompileTo('hello');
+ });
+
+ it('deep @foo triggers automatic top-level data', () => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+ const helpers = Handlebars.createFrame(kbnHandlebarsEnv!.helpers);
+
+ helpers.let = function (options: HelperOptions) {
+ const frame = Handlebars.createFrame(options.data);
+
+ for (const prop in options.hash) {
+ if (prop in options.hash) {
+ frame[prop] = options.hash[prop];
+ }
+ }
+ return options.fn(this, { data: frame });
+ };
+
+ expectTemplate(
+ '{{#let world="world"}}{{#if foo}}{{#if foo}}Hello {{@world}}{{/if}}{{/if}}{{/let}}'
+ )
+ .withInput({ foo: true })
+ .withHelpers(helpers)
+ .toCompileTo('Hello world');
+
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('parameter data can be looked up via @foo', () => {
+ expectTemplate('{{hello @world}}')
+ .withRuntimeOptions({ data: { world: 'world' } })
+ .withHelper('hello', function (noun) {
+ return 'Hello ' + noun;
+ })
+ .toCompileTo('Hello world');
+ });
+
+ it('hash values can be looked up via @foo', () => {
+ expectTemplate('{{hello noun=@world}}')
+ .withRuntimeOptions({ data: { world: 'world' } })
+ .withHelper('hello', function (options) {
+ return 'Hello ' + options.hash.noun;
+ })
+ .toCompileTo('Hello world');
+ });
+
+ it('nested parameter data can be looked up via @foo.bar', () => {
+ expectTemplate('{{hello @world.bar}}')
+ .withRuntimeOptions({ data: { world: { bar: 'world' } } })
+ .withHelper('hello', function (noun) {
+ return 'Hello ' + noun;
+ })
+ .toCompileTo('Hello world');
+ });
+
+ it('nested parameter data does not fail with @world.bar', () => {
+ expectTemplate('{{hello @world.bar}}')
+ .withRuntimeOptions({ data: { foo: { bar: 'world' } } })
+ .withHelper('hello', function (noun) {
+ return 'Hello ' + noun;
+ })
+ .toCompileTo('Hello undefined');
+ });
+
+ it('parameter data throws when using complex scope references', () => {
+ expectTemplate('{{#goodbyes}}{{text}} cruel {{@foo/../name}}! {{/goodbyes}}').toThrow(Error);
+ });
+
+ it('data can be functions', () => {
+ expectTemplate('{{@hello}}')
+ .withRuntimeOptions({
+ data: {
+ hello() {
+ return 'hello';
+ },
+ },
+ })
+ .toCompileTo('hello');
+ });
+
+ it('data can be functions with params', () => {
+ expectTemplate('{{@hello "hello"}}')
+ .withRuntimeOptions({
+ data: {
+ hello(arg: any) {
+ return arg;
+ },
+ },
+ })
+ .toCompileTo('hello');
+ });
+
+ it('data is inherited downstream', () => {
+ expectTemplate(
+ '{{#let foo=1 bar=2}}{{#let foo=bar.baz}}{{@bar}}{{@foo}}{{/let}}{{@foo}}{{/let}}'
+ )
+ .withInput({ bar: { baz: 'hello world' } })
+ .withCompileOptions({ data: true })
+ .withHelper('let', function (this: any, options) {
+ const frame = Handlebars.createFrame(options.data);
+ for (const prop in options.hash) {
+ if (prop in options.hash) {
+ frame[prop] = options.hash[prop];
+ }
+ }
+ return options.fn(this, { data: frame });
+ })
+ .withRuntimeOptions({ data: {} })
+ .toCompileTo('2hello world1');
+ });
+
+ it('passing in data to a compiled function that expects data - works with helpers in partials', () => {
+ expectTemplate('{{>myPartial}}')
+ .withCompileOptions({ data: true })
+ .withPartial('myPartial', '{{hello}}')
+ .withHelper('hello', function (this: any, options: HelperOptions) {
+ return options.data.adjective + ' ' + this.noun;
+ })
+ .withInput({ noun: 'cat' })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('happy cat');
+ });
+
+ it('passing in data to a compiled function that expects data - works with helpers and parameters', () => {
+ expectTemplate('{{hello world}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (this: any, noun, options) {
+ return options.data.adjective + ' ' + noun + (this.exclaim ? '!' : '');
+ })
+ .withInput({ exclaim: true, world: 'world' })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('happy world!');
+ });
+
+ it('passing in data to a compiled function that expects data - works with block helpers', () => {
+ expectTemplate('{{#hello}}{{world}}{{/hello}}')
+ .withCompileOptions({
+ data: true,
+ })
+ .withHelper('hello', function (this: any, options) {
+ return options.fn(this);
+ })
+ .withHelper('world', function (this: any, options) {
+ return options.data.adjective + ' world' + (this.exclaim ? '!' : '');
+ })
+ .withInput({ exclaim: true })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('happy world!');
+ });
+
+ it('passing in data to a compiled function that expects data - works with block helpers that use ..', () => {
+ expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (options) {
+ return options.fn({ exclaim: '?' });
+ })
+ .withHelper('world', function (this: any, thing, options) {
+ return options.data.adjective + ' ' + thing + (this.exclaim || '');
+ })
+ .withInput({ exclaim: true, zomg: 'world' })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('happy world?');
+ });
+
+ it('passing in data to a compiled function that expects data - data is passed to with block helpers where children use ..', () => {
+ expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (options) {
+ return options.data.accessData + ' ' + options.fn({ exclaim: '?' });
+ })
+ .withHelper('world', function (this: any, thing, options) {
+ return options.data.adjective + ' ' + thing + (this.exclaim || '');
+ })
+ .withInput({ exclaim: true, zomg: 'world' })
+ .withRuntimeOptions({ data: { adjective: 'happy', accessData: '#win' } })
+ .toCompileTo('#win happy world?');
+ });
+
+ it('you can override inherited data when invoking a helper', () => {
+ expectTemplate('{{#hello}}{{world zomg}}{{/hello}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (options) {
+ return options.fn({ exclaim: '?', zomg: 'world' }, { data: { adjective: 'sad' } });
+ })
+ .withHelper('world', function (this: any, thing, options) {
+ return options.data.adjective + ' ' + thing + (this.exclaim || '');
+ })
+ .withInput({ exclaim: true, zomg: 'planet' })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('sad world?');
+ });
+
+ it('you can override inherited data when invoking a helper with depth', () => {
+ expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}')
+ .withCompileOptions({ data: true })
+ .withHelper('hello', function (options) {
+ return options.fn({ exclaim: '?' }, { data: { adjective: 'sad' } });
+ })
+ .withHelper('world', function (this: any, thing, options) {
+ return options.data.adjective + ' ' + thing + (this.exclaim || '');
+ })
+ .withInput({ exclaim: true, zomg: 'world' })
+ .withRuntimeOptions({ data: { adjective: 'happy' } })
+ .toCompileTo('sad world?');
+ });
+
+ describe('@root', () => {
+ it('the root context can be looked up via @root', () => {
+ expectTemplate('{{@root.foo}}')
+ .withInput({ foo: 'hello' })
+ .withRuntimeOptions({ data: {} })
+ .toCompileTo('hello');
+
+ expectTemplate('{{@root.foo}}').withInput({ foo: 'hello' }).toCompileTo('hello');
+ });
+
+ it('passed root values take priority', () => {
+ expectTemplate('{{@root.foo}}')
+ .withInput({ foo: 'should not be used' })
+ .withRuntimeOptions({ data: { root: { foo: 'hello' } } })
+ .toCompileTo('hello');
+ });
+ });
+
+ describe('nesting', () => {
+ it('the root context can be looked up via @root', () => {
+ expectTemplate(
+ '{{#helper}}{{#helper}}{{@./depth}} {{@../depth}} {{@../../depth}}{{/helper}}{{/helper}}'
+ )
+ .withInput({ foo: 'hello' })
+ .withHelper('helper', function (this: any, options) {
+ const frame = Handlebars.createFrame(options.data);
+ frame.depth = options.data.depth + 1;
+ return options.fn(this, { data: frame });
+ })
+ .withRuntimeOptions({
+ data: {
+ depth: 0,
+ },
+ })
+ .toCompileTo('2 1 0');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.helpers.test.ts b/dev/lib/handlebars/src/spec/index.helpers.test.ts
new file mode 100644
index 00000000..4cfa39bb
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.helpers.test.ts
@@ -0,0 +1,958 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, { type HelperOptions } from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+beforeEach(() => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+});
+
+afterEach(() => {
+ global.kbnHandlebarsEnv = null;
+});
+
+describe('helpers', () => {
+ it('helper with complex lookup$', () => {
+ expectTemplate('{{#goodbyes}}{{{link ../prefix}}}{{/goodbyes}}')
+ .withInput({
+ prefix: '/root',
+ goodbyes: [{ text: 'Goodbye', url: 'goodbye' }],
+ })
+ .withHelper('link', function (this: any, prefix) {
+ return '<a href="' + prefix + '/' + this.url + '">' + this.text + '</a>';
+ })
+ .toCompileTo('<a href="/root/goodbye">Goodbye</a>');
+ });
+
+ it('helper for raw block gets raw content', () => {
+ expectTemplate('{{{{raw}}}} {{test}} {{{{/raw}}}}')
+ .withInput({ test: 'hello' })
+ .withHelper('raw', function (options: HelperOptions) {
+ return options.fn();
+ })
+ .toCompileTo(' {{test}} ');
+ });
+
+ it('helper for raw block gets parameters', () => {
+ expectTemplate('{{{{raw 1 2 3}}}} {{test}} {{{{/raw}}}}')
+ .withInput({ test: 'hello' })
+ .withHelper('raw', function (a, b, c, options: HelperOptions) {
+ const ret = options.fn() + a + b + c;
+ return ret;
+ })
+ .toCompileTo(' {{test}} 123');
+ });
+
+ describe('raw block parsing (with identity helper-function)', () => {
+ function runWithIdentityHelper(template: string, expected: string) {
+ expectTemplate(template)
+ .withHelper('identity', function (options: HelperOptions) {
+ return options.fn();
+ })
+ .toCompileTo(expected);
+ }
+
+ it('helper for nested raw block gets raw content', () => {
+ runWithIdentityHelper(
+ '{{{{identity}}}} {{{{b}}}} {{{{/b}}}} {{{{/identity}}}}',
+ ' {{{{b}}}} {{{{/b}}}} '
+ );
+ });
+
+ it('helper for nested raw block works with empty content', () => {
+ runWithIdentityHelper('{{{{identity}}}}{{{{/identity}}}}', '');
+ });
+
+ it.skip('helper for nested raw block works if nested raw blocks are broken', () => {
+ // This test was introduced in 4.4.4, but it was not the actual problem that lead to the patch release
+ // The test is deactivated, because in 3.x this template cases an exception and it also does not work in 4.4.3
+ // If anyone can make this template work without breaking everything else, then go for it,
+ // but for now, this is just a known bug, that will be documented.
+ runWithIdentityHelper(
+ '{{{{identity}}}} {{{{a}}}} {{{{ {{{{/ }}}} }}}} {{{{/identity}}}}',
+ ' {{{{a}}}} {{{{ {{{{/ }}}} }}}} '
+ );
+ });
+
+ it('helper for nested raw block closes after first matching close', () => {
+ runWithIdentityHelper(
+ '{{{{identity}}}}abc{{{{/identity}}}} {{{{identity}}}}abc{{{{/identity}}}}',
+ 'abc abc'
+ );
+ });
+
+ it('helper for nested raw block throw exception when with missing closing braces', () => {
+ const string = '{{{{a}}}} {{{{/a';
+ expectTemplate(string).toThrow();
+ });
+ });
+
+ it('helper block with identical context', () => {
+ expectTemplate('{{#goodbyes}}{{name}}{{/goodbyes}}')
+ .withInput({ name: 'Alan' })
+ .withHelper('goodbyes', function (this: any, options: HelperOptions) {
+ let out = '';
+ const byes = ['Goodbye', 'goodbye', 'GOODBYE'];
+ for (let i = 0, j = byes.length; i < j; i++) {
+ out += byes[i] + ' ' + options.fn(this) + '! ';
+ }
+ return out;
+ })
+ .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! ');
+ });
+
+ it('helper block with complex lookup expression', () => {
+ expectTemplate('{{#goodbyes}}{{../name}}{{/goodbyes}}')
+ .withInput({ name: 'Alan' })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ let out = '';
+ const byes = ['Goodbye', 'goodbye', 'GOODBYE'];
+ for (let i = 0, j = byes.length; i < j; i++) {
+ out += byes[i] + ' ' + options.fn({}) + '! ';
+ }
+ return out;
+ })
+ .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! ');
+ });
+
+ it('helper with complex lookup and nested template', () => {
+ expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}')
+ .withInput({
+ prefix: '/root',
+ goodbyes: [{ text: 'Goodbye', url: 'goodbye' }],
+ })
+ .withHelper('link', function (this: any, prefix, options: HelperOptions) {
+ return '<a href="' + prefix + '/' + this.url + '">' + options.fn(this) + '</a>';
+ })
+ .toCompileTo('<a href="/root/goodbye">Goodbye</a>');
+ });
+
+ it('helper with complex lookup and nested template in VM+Compiler', () => {
+ expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}')
+ .withInput({
+ prefix: '/root',
+ goodbyes: [{ text: 'Goodbye', url: 'goodbye' }],
+ })
+ .withHelper('link', function (this: any, prefix, options: HelperOptions) {
+ return '<a href="' + prefix + '/' + this.url + '">' + options.fn(this) + '</a>';
+ })
+ .toCompileTo('<a href="/root/goodbye">Goodbye</a>');
+ });
+
+ it('helper returning undefined value', () => {
+ expectTemplate(' {{nothere}}')
+ .withHelpers({
+ nothere() {},
+ })
+ .toCompileTo(' ');
+
+ expectTemplate(' {{#nothere}}{{/nothere}}')
+ .withHelpers({
+ nothere() {},
+ })
+ .toCompileTo(' ');
+ });
+
+ it('block helper', () => {
+ expectTemplate('{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!')
+ .withInput({ world: 'world' })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ return options.fn({ text: 'GOODBYE' });
+ })
+ .toCompileTo('GOODBYE! cruel world!');
+ });
+
+ it('block helper staying in the same context', () => {
+ expectTemplate('{{#form}}<p>{{name}}</p>{{/form}}')
+ .withInput({ name: 'Yehuda' })
+ .withHelper('form', function (this: any, options: HelperOptions) {
+ return '<form>' + options.fn(this) + '</form>';
+ })
+ .toCompileTo('<form><p>Yehuda</p></form>');
+ });
+
+ it('block helper should have context in this', () => {
+ function link(this: any, options: HelperOptions) {
+ return '<a href="/people/' + this.id + '">' + options.fn(this) + '</a>';
+ }
+
+ expectTemplate('<ul>{{#people}}<li>{{#link}}{{name}}{{/link}}</li>{{/people}}</ul>')
+ .withInput({
+ people: [
+ { name: 'Alan', id: 1 },
+ { name: 'Yehuda', id: 2 },
+ ],
+ })
+ .withHelper('link', link)
+ .toCompileTo(
+ '<ul><li><a href="/people/1">Alan</a></li><li><a href="/people/2">Yehuda</a></li></ul>'
+ );
+ });
+
+ it('block helper for undefined value', () => {
+ expectTemplate("{{#empty}}shouldn't render{{/empty}}").toCompileTo('');
+ });
+
+ it('block helper passing a new context', () => {
+ expectTemplate('{{#form yehuda}}<p>{{name}}</p>{{/form}}')
+ .withInput({ yehuda: { name: 'Yehuda' } })
+ .withHelper('form', function (context, options: HelperOptions) {
+ return '<form>' + options.fn(context) + '</form>';
+ })
+ .toCompileTo('<form><p>Yehuda</p></form>');
+ });
+
+ it('block helper passing a complex path context', () => {
+ expectTemplate('{{#form yehuda/cat}}<p>{{name}}</p>{{/form}}')
+ .withInput({ yehuda: { name: 'Yehuda', cat: { name: 'Harold' } } })
+ .withHelper('form', function (context, options: HelperOptions) {
+ return '<form>' + options.fn(context) + '</form>';
+ })
+ .toCompileTo('<form><p>Harold</p></form>');
+ });
+
+ it('nested block helpers', () => {
+ expectTemplate('{{#form yehuda}}<p>{{name}}</p>{{#link}}Hello{{/link}}{{/form}}')
+ .withInput({
+ yehuda: { name: 'Yehuda' },
+ })
+ .withHelper('link', function (this: any, options: HelperOptions) {
+ return '<a href="' + this.name + '">' + options.fn(this) + '</a>';
+ })
+ .withHelper('form', function (context, options: HelperOptions) {
+ return '<form>' + options.fn(context) + '</form>';
+ })
+ .toCompileTo('<form><p>Yehuda</p><a href="Yehuda">Hello</a></form>');
+ });
+
+ it('block helper inverted sections', () => {
+ const string = "{{#list people}}{{name}}{{^}}<em>Nobody's here</em>{{/list}}";
+ function list(this: any, context: any, options: HelperOptions) {
+ if (context.length > 0) {
+ let out = '<ul>';
+ for (let i = 0, j = context.length; i < j; i++) {
+ out += '<li>';
+ out += options.fn(context[i]);
+ out += '</li>';
+ }
+ out += '</ul>';
+ return out;
+ } else {
+ return '<p>' + options.inverse(this) + '</p>';
+ }
+ }
+
+ // the meaning here may be kind of hard to catch, but list.not is always called,
+ // so we should see the output of both
+ expectTemplate(string)
+ .withInput({ people: [{ name: 'Alan' }, { name: 'Yehuda' }] })
+ .withHelpers({ list })
+ .toCompileTo('<ul><li>Alan</li><li>Yehuda</li></ul>');
+
+ expectTemplate(string)
+ .withInput({ people: [] })
+ .withHelpers({ list })
+ .toCompileTo("<p><em>Nobody's here</em></p>");
+
+ expectTemplate('{{#list people}}Hello{{^}}{{message}}{{/list}}')
+ .withInput({
+ people: [],
+ message: "Nobody's here",
+ })
+ .withHelpers({ list })
+ .toCompileTo('<p>Nobody&#x27;s here</p>');
+ });
+
+ it('pathed lambas with parameters', () => {
+ const hash = {
+ helper: () => 'winning',
+ };
+ // @ts-expect-error
+ hash.hash = hash;
+
+ const helpers = {
+ './helper': () => 'fail',
+ };
+
+ expectTemplate('{{./helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning');
+ expectTemplate('{{hash/helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning');
+ });
+
+ describe('helpers hash', () => {
+ it('providing a helpers hash', () => {
+ expectTemplate('Goodbye {{cruel}} {{world}}!')
+ .withInput({ cruel: 'cruel' })
+ .withHelpers({
+ world() {
+ return 'world';
+ },
+ })
+ .toCompileTo('Goodbye cruel world!');
+
+ expectTemplate('Goodbye {{#iter}}{{cruel}} {{world}}{{/iter}}!')
+ .withInput({ iter: [{ cruel: 'cruel' }] })
+ .withHelpers({
+ world() {
+ return 'world';
+ },
+ })
+ .toCompileTo('Goodbye cruel world!');
+ });
+
+ it('in cases of conflict, helpers win', () => {
+ expectTemplate('{{{lookup}}}')
+ .withInput({ lookup: 'Explicit' })
+ .withHelpers({
+ lookup() {
+ return 'helpers';
+ },
+ })
+ .toCompileTo('helpers');
+
+ expectTemplate('{{lookup}}')
+ .withInput({ lookup: 'Explicit' })
+ .withHelpers({
+ lookup() {
+ return 'helpers';
+ },
+ })
+ .toCompileTo('helpers');
+ });
+
+ it('the helpers hash is available is nested contexts', () => {
+ expectTemplate('{{#outer}}{{#inner}}{{helper}}{{/inner}}{{/outer}}')
+ .withInput({ outer: { inner: { unused: [] } } })
+ .withHelpers({
+ helper() {
+ return 'helper';
+ },
+ })
+ .toCompileTo('helper');
+ });
+
+ it('the helper hash should augment the global hash', () => {
+ kbnHandlebarsEnv!.registerHelper('test_helper', function () {
+ return 'found it!';
+ });
+
+ expectTemplate('{{test_helper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}')
+ .withInput({ cruel: 'cruel' })
+ .withHelpers({
+ world() {
+ return 'world!';
+ },
+ })
+ .toCompileTo('found it! Goodbye cruel world!!');
+ });
+ });
+
+ describe('registration', () => {
+ it('unregisters', () => {
+ deleteAllKeys(kbnHandlebarsEnv!.helpers);
+
+ kbnHandlebarsEnv!.registerHelper('foo', function () {
+ return 'fail';
+ });
+ expect(kbnHandlebarsEnv!.helpers.foo).toBeDefined();
+ kbnHandlebarsEnv!.unregisterHelper('foo');
+ expect(kbnHandlebarsEnv!.helpers.foo).toBeUndefined();
+ });
+
+ it('allows multiple globals', () => {
+ const ifHelper = kbnHandlebarsEnv!.helpers.if;
+ deleteAllKeys(kbnHandlebarsEnv!.helpers);
+
+ kbnHandlebarsEnv!.registerHelper({
+ if: ifHelper,
+ world() {
+ return 'world!';
+ },
+ testHelper() {
+ return 'found it!';
+ },
+ });
+
+ expectTemplate('{{testHelper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}')
+ .withInput({ cruel: 'cruel' })
+ .toCompileTo('found it! Goodbye cruel world!!');
+ });
+
+ it('fails with multiple and args', () => {
+ expect(() => {
+ kbnHandlebarsEnv!.registerHelper(
+ // @ts-expect-error TypeScript is complaining about the invalid input just as the thrown error
+ {
+ world() {
+ return 'world!';
+ },
+ testHelper() {
+ return 'found it!';
+ },
+ },
+ {}
+ );
+ }).toThrow('Arg not supported with multiple helpers');
+ });
+ });
+
+ it('decimal number literals work', () => {
+ expectTemplate('Message: {{hello -1.2 1.2}}')
+ .withHelper('hello', function (times, times2) {
+ if (typeof times !== 'number') {
+ times = 'NaN';
+ }
+ if (typeof times2 !== 'number') {
+ times2 = 'NaN';
+ }
+ return 'Hello ' + times + ' ' + times2 + ' times';
+ })
+ .toCompileTo('Message: Hello -1.2 1.2 times');
+ });
+
+ it('negative number literals work', () => {
+ expectTemplate('Message: {{hello -12}}')
+ .withHelper('hello', function (times) {
+ if (typeof times !== 'number') {
+ times = 'NaN';
+ }
+ return 'Hello ' + times + ' times';
+ })
+ .toCompileTo('Message: Hello -12 times');
+ });
+
+ describe('String literal parameters', () => {
+ it('simple literals work', () => {
+ expectTemplate('Message: {{hello "world" 12 true false}}')
+ .withHelper('hello', function (param, times, bool1, bool2) {
+ if (typeof times !== 'number') {
+ times = 'NaN';
+ }
+ if (typeof bool1 !== 'boolean') {
+ bool1 = 'NaB';
+ }
+ if (typeof bool2 !== 'boolean') {
+ bool2 = 'NaB';
+ }
+ return 'Hello ' + param + ' ' + times + ' times: ' + bool1 + ' ' + bool2;
+ })
+ .toCompileTo('Message: Hello world 12 times: true false');
+ });
+
+ it('using a quote in the middle of a parameter raises an error', () => {
+ expectTemplate('Message: {{hello wo"rld"}}').toThrow(Error);
+ });
+
+ it('escaping a String is possible', () => {
+ expectTemplate('Message: {{{hello "\\"world\\""}}}')
+ .withHelper('hello', function (param) {
+ return 'Hello ' + param;
+ })
+ .toCompileTo('Message: Hello "world"');
+ });
+
+ it("it works with ' marks", () => {
+ expectTemplate('Message: {{{hello "Alan\'s world"}}}')
+ .withHelper('hello', function (param) {
+ return 'Hello ' + param;
+ })
+ .toCompileTo("Message: Hello Alan's world");
+ });
+ });
+
+ describe('multiple parameters', () => {
+ it('simple multi-params work', () => {
+ expectTemplate('Message: {{goodbye cruel world}}')
+ .withInput({ cruel: 'cruel', world: 'world' })
+ .withHelper('goodbye', function (cruel, world) {
+ return 'Goodbye ' + cruel + ' ' + world;
+ })
+ .toCompileTo('Message: Goodbye cruel world');
+ });
+
+ it('block multi-params work', () => {
+ expectTemplate('Message: {{#goodbye cruel world}}{{greeting}} {{adj}} {{noun}}{{/goodbye}}')
+ .withInput({ cruel: 'cruel', world: 'world' })
+ .withHelper('goodbye', function (cruel, world, options: HelperOptions) {
+ return options.fn({ greeting: 'Goodbye', adj: cruel, noun: world });
+ })
+ .toCompileTo('Message: Goodbye cruel world');
+ });
+ });
+
+ describe('hash', () => {
+ it('helpers can take an optional hash', () => {
+ expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" times=12}}')
+ .withHelper('goodbye', function (options: HelperOptions) {
+ return (
+ 'GOODBYE ' +
+ options.hash.cruel +
+ ' ' +
+ options.hash.world +
+ ' ' +
+ options.hash.times +
+ ' TIMES'
+ );
+ })
+ .toCompileTo('GOODBYE CRUEL WORLD 12 TIMES');
+ });
+
+ it('helpers can take an optional hash with booleans', () => {
+ function goodbye(options: HelperOptions) {
+ if (options.hash.print === true) {
+ return 'GOODBYE ' + options.hash.cruel + ' ' + options.hash.world;
+ } else if (options.hash.print === false) {
+ return 'NOT PRINTING';
+ } else {
+ return 'THIS SHOULD NOT HAPPEN';
+ }
+ }
+
+ expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=true}}')
+ .withHelper('goodbye', goodbye)
+ .toCompileTo('GOODBYE CRUEL WORLD');
+
+ expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=false}}')
+ .withHelper('goodbye', goodbye)
+ .toCompileTo('NOT PRINTING');
+ });
+
+ it('block helpers can take an optional hash', () => {
+ expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}')
+ .withHelper('goodbye', function (this: any, options: HelperOptions) {
+ return (
+ 'GOODBYE ' +
+ options.hash.cruel +
+ ' ' +
+ options.fn(this) +
+ ' ' +
+ options.hash.times +
+ ' TIMES'
+ );
+ })
+ .toCompileTo('GOODBYE CRUEL world 12 TIMES');
+ });
+
+ it('block helpers can take an optional hash with single quoted stings', () => {
+ expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}')
+ .withHelper('goodbye', function (this: any, options: HelperOptions) {
+ return (
+ 'GOODBYE ' +
+ options.hash.cruel +
+ ' ' +
+ options.fn(this) +
+ ' ' +
+ options.hash.times +
+ ' TIMES'
+ );
+ })
+ .toCompileTo('GOODBYE CRUEL world 12 TIMES');
+ });
+
+ it('block helpers can take an optional hash with booleans', () => {
+ function goodbye(this: any, options: HelperOptions) {
+ if (options.hash.print === true) {
+ return 'GOODBYE ' + options.hash.cruel + ' ' + options.fn(this);
+ } else if (options.hash.print === false) {
+ return 'NOT PRINTING';
+ } else {
+ return 'THIS SHOULD NOT HAPPEN';
+ }
+ }
+
+ expectTemplate('{{#goodbye cruel="CRUEL" print=true}}world{{/goodbye}}')
+ .withHelper('goodbye', goodbye)
+ .toCompileTo('GOODBYE CRUEL world');
+
+ expectTemplate('{{#goodbye cruel="CRUEL" print=false}}world{{/goodbye}}')
+ .withHelper('goodbye', goodbye)
+ .toCompileTo('NOT PRINTING');
+ });
+ });
+
+ describe('helperMissing', () => {
+ it('if a context is not found, helperMissing is used', () => {
+ expectTemplate('{{hello}} {{link_to world}}').toThrow(/Missing helper: "link_to"/);
+ });
+
+ it('if a context is not found, custom helperMissing is used', () => {
+ expectTemplate('{{hello}} {{link_to world}}')
+ .withInput({ hello: 'Hello', world: 'world' })
+ .withHelper('helperMissing', function (mesg, options: HelperOptions) {
+ if (options.name === 'link_to') {
+ return new Handlebars.SafeString('<a>' + mesg + '</a>');
+ }
+ })
+ .toCompileTo('Hello <a>world</a>');
+ });
+
+ it('if a value is not found, custom helperMissing is used', () => {
+ expectTemplate('{{hello}} {{link_to}}')
+ .withInput({ hello: 'Hello', world: 'world' })
+ .withHelper('helperMissing', function (options: HelperOptions) {
+ if (options.name === 'link_to') {
+ return new Handlebars.SafeString('<a>winning</a>');
+ }
+ })
+ .toCompileTo('Hello <a>winning</a>');
+ });
+ });
+
+ describe('knownHelpers', () => {
+ it('Known helper should render helper', () => {
+ expectTemplate('{{hello}}')
+ .withCompileOptions({
+ knownHelpers: { hello: true },
+ })
+ .withHelper('hello', function () {
+ return 'foo';
+ })
+ .toCompileTo('foo');
+ });
+
+ it('Unknown helper in knownHelpers only mode should be passed as undefined', () => {
+ expectTemplate('{{typeof hello}}')
+ .withCompileOptions({
+ knownHelpers: { typeof: true },
+ knownHelpersOnly: true,
+ })
+ .withHelper('typeof', function (arg) {
+ return typeof arg;
+ })
+ .withHelper('hello', function () {
+ return 'foo';
+ })
+ .toCompileTo('undefined');
+ });
+
+ it('Builtin helpers available in knownHelpers only mode', () => {
+ expectTemplate('{{#unless foo}}bar{{/unless}}')
+ .withCompileOptions({
+ knownHelpersOnly: true,
+ })
+ .toCompileTo('bar');
+ });
+
+ it('Field lookup works in knownHelpers only mode', () => {
+ expectTemplate('{{foo}}')
+ .withCompileOptions({
+ knownHelpersOnly: true,
+ })
+ .withInput({ foo: 'bar' })
+ .toCompileTo('bar');
+ });
+
+ it('Conditional blocks work in knownHelpers only mode', () => {
+ expectTemplate('{{#foo}}bar{{/foo}}')
+ .withCompileOptions({
+ knownHelpersOnly: true,
+ })
+ .withInput({ foo: 'baz' })
+ .toCompileTo('bar');
+ });
+
+ it('Invert blocks work in knownHelpers only mode', () => {
+ expectTemplate('{{^foo}}bar{{/foo}}')
+ .withCompileOptions({
+ knownHelpersOnly: true,
+ })
+ .withInput({ foo: false })
+ .toCompileTo('bar');
+ });
+
+ it('Functions are bound to the context in knownHelpers only mode', () => {
+ expectTemplate('{{foo}}')
+ .withCompileOptions({
+ knownHelpersOnly: true,
+ })
+ .withInput({
+ foo() {
+ return this.bar;
+ },
+ bar: 'bar',
+ })
+ .toCompileTo('bar');
+ });
+
+ it('Unknown helper call in knownHelpers only mode should throw', () => {
+ expectTemplate('{{typeof hello}}')
+ .withCompileOptions({ knownHelpersOnly: true })
+ .toThrow(Error);
+ });
+ });
+
+ describe('blockHelperMissing', () => {
+ it('lambdas are resolved by blockHelperMissing, not handlebars proper', () => {
+ expectTemplate('{{#truthy}}yep{{/truthy}}')
+ .withInput({
+ truthy() {
+ return true;
+ },
+ })
+ .toCompileTo('yep');
+ });
+
+ it('lambdas resolved by blockHelperMissing are bound to the context', () => {
+ expectTemplate('{{#truthy}}yep{{/truthy}}')
+ .withInput({
+ truthy() {
+ return this.truthiness();
+ },
+ truthiness() {
+ return false;
+ },
+ })
+ .toCompileTo('');
+ });
+ });
+
+ describe('name field', () => {
+ const helpers = {
+ blockHelperMissing(...args: any[]) {
+ return 'missing: ' + args[args.length - 1].name;
+ },
+ helperMissing(...args: any[]) {
+ return 'helper missing: ' + args[args.length - 1].name;
+ },
+ helper(...args: any[]) {
+ return 'ran: ' + args[args.length - 1].name;
+ },
+ };
+
+ it('should include in ambiguous mustache calls', () => {
+ expectTemplate('{{helper}}').withHelpers(helpers).toCompileTo('ran: helper');
+ });
+
+ it('should include in helper mustache calls', () => {
+ expectTemplate('{{helper 1}}').withHelpers(helpers).toCompileTo('ran: helper');
+ });
+
+ it('should include in ambiguous block calls', () => {
+ expectTemplate('{{#helper}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper');
+ });
+
+ it('should include in simple block calls', () => {
+ expectTemplate('{{#./helper}}{{/./helper}}')
+ .withHelpers(helpers)
+ .toCompileTo('missing: ./helper');
+ });
+
+ it('should include in helper block calls', () => {
+ expectTemplate('{{#helper 1}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper');
+ });
+
+ it('should include in known helper calls', () => {
+ expectTemplate('{{helper}}')
+ .withCompileOptions({
+ knownHelpers: { helper: true },
+ knownHelpersOnly: true,
+ })
+ .withHelpers(helpers)
+ .toCompileTo('ran: helper');
+ });
+
+ it('should include full id', () => {
+ expectTemplate('{{#foo.helper}}{{/foo.helper}}')
+ .withInput({ foo: {} })
+ .withHelpers(helpers)
+ .toCompileTo('missing: foo.helper');
+ });
+
+ it('should include full id if a hash is passed', () => {
+ expectTemplate('{{#foo.helper bar=baz}}{{/foo.helper}}')
+ .withInput({ foo: {} })
+ .withHelpers(helpers)
+ .toCompileTo('helper missing: foo.helper');
+ });
+ });
+
+ describe('name conflicts', () => {
+ it('helpers take precedence over same-named context properties', () => {
+ expectTemplate('{{goodbye}} {{cruel world}}')
+ .withHelper('goodbye', function (this: any) {
+ return this.goodbye.toUpperCase();
+ })
+ .withHelper('cruel', function (world) {
+ return 'cruel ' + world.toUpperCase();
+ })
+ .withInput({
+ goodbye: 'goodbye',
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel WORLD');
+ });
+
+ it('helpers take precedence over same-named context properties$', () => {
+ expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}}')
+ .withHelper('goodbye', function (this: any, options: HelperOptions) {
+ return this.goodbye.toUpperCase() + options.fn(this);
+ })
+ .withHelper('cruel', function (world) {
+ return 'cruel ' + world.toUpperCase();
+ })
+ .withInput({
+ goodbye: 'goodbye',
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel WORLD');
+ });
+
+ it('Scoped names take precedence over helpers', () => {
+ expectTemplate('{{this.goodbye}} {{cruel world}} {{cruel this.goodbye}}')
+ .withHelper('goodbye', function (this: any) {
+ return this.goodbye.toUpperCase();
+ })
+ .withHelper('cruel', function (world) {
+ return 'cruel ' + world.toUpperCase();
+ })
+ .withInput({
+ goodbye: 'goodbye',
+ world: 'world',
+ })
+ .toCompileTo('goodbye cruel WORLD cruel GOODBYE');
+ });
+
+ it('Scoped names take precedence over block helpers', () => {
+ expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}} {{this.goodbye}}')
+ .withHelper('goodbye', function (this: any, options: HelperOptions) {
+ return this.goodbye.toUpperCase() + options.fn(this);
+ })
+ .withHelper('cruel', function (world) {
+ return 'cruel ' + world.toUpperCase();
+ })
+ .withInput({
+ goodbye: 'goodbye',
+ world: 'world',
+ })
+ .toCompileTo('GOODBYE cruel WORLD goodbye');
+ });
+ });
+
+ describe('block params', () => {
+ it('should take presedence over context values', () => {
+ expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}')
+ .withInput({ value: 'foo' })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ expect(options.fn.blockParams).toEqual(1);
+ return options.fn({ value: 'bar' }, { blockParams: [1, 2] });
+ })
+ .toCompileTo('1foo');
+ });
+
+ it('should take presedence over helper values', () => {
+ expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}')
+ .withHelper('value', function () {
+ return 'foo';
+ })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ expect(options.fn.blockParams).toEqual(1);
+ return options.fn({}, { blockParams: [1, 2] });
+ })
+ .toCompileTo('1foo');
+ });
+
+ it('should not take presedence over pathed values', () => {
+ expectTemplate('{{#goodbyes as |value|}}{{./value}}{{/goodbyes}}{{value}}')
+ .withInput({ value: 'bar' })
+ .withHelper('value', function () {
+ return 'foo';
+ })
+ .withHelper('goodbyes', function (this: any, options: HelperOptions) {
+ expect(options.fn.blockParams).toEqual(1);
+ return options.fn(this, { blockParams: [1, 2] });
+ })
+ .toCompileTo('barfoo');
+ });
+
+ it('should take presednece over parent block params', () => {
+ let value: number;
+ expectTemplate(
+ '{{#goodbyes as |value|}}{{#goodbyes}}{{value}}{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{/goodbyes}}{{/goodbyes}}{{value}}',
+ {
+ beforeEach() {
+ value = 1;
+ },
+ }
+ )
+ .withInput({ value: 'foo' })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ return options.fn(
+ { value: 'bar' },
+ {
+ blockParams: options.fn.blockParams === 1 ? [value++, value++] : undefined,
+ }
+ );
+ })
+ .toCompileTo('13foo');
+ });
+
+ it('should allow block params on chained helpers', () => {
+ expectTemplate('{{#if bar}}{{else goodbyes as |value|}}{{value}}{{/if}}{{value}}')
+ .withInput({ value: 'foo' })
+ .withHelper('goodbyes', function (options: HelperOptions) {
+ expect(options.fn.blockParams).toEqual(1);
+ return options.fn({ value: 'bar' }, { blockParams: [1, 2] });
+ })
+ .toCompileTo('1foo');
+ });
+ });
+
+ describe('built-in helpers malformed arguments ', () => {
+ it('if helper - too few arguments', () => {
+ expectTemplate('{{#if}}{{/if}}').toThrow(/#if requires exactly one argument/);
+ });
+
+ it('if helper - too many arguments, string', () => {
+ expectTemplate('{{#if test "string"}}{{/if}}').toThrow(/#if requires exactly one argument/);
+ });
+
+ it('if helper - too many arguments, undefined', () => {
+ expectTemplate('{{#if test undefined}}{{/if}}').toThrow(/#if requires exactly one argument/);
+ });
+
+ it('if helper - too many arguments, null', () => {
+ expectTemplate('{{#if test null}}{{/if}}').toThrow(/#if requires exactly one argument/);
+ });
+
+ it('unless helper - too few arguments', () => {
+ expectTemplate('{{#unless}}{{/unless}}').toThrow(/#unless requires exactly one argument/);
+ });
+
+ it('unless helper - too many arguments', () => {
+ expectTemplate('{{#unless test null}}{{/unless}}').toThrow(
+ /#unless requires exactly one argument/
+ );
+ });
+
+ it('with helper - too few arguments', () => {
+ expectTemplate('{{#with}}{{/with}}').toThrow(/#with requires exactly one argument/);
+ });
+
+ it('with helper - too many arguments', () => {
+ expectTemplate('{{#with test "string"}}{{/with}}').toThrow(
+ /#with requires exactly one argument/
+ );
+ });
+ });
+
+ describe('the lookupProperty-option', () => {
+ it('should be passed to custom helpers', () => {
+ expectTemplate('{{testHelper}}')
+ .withHelper('testHelper', function testHelper(this: any, options: HelperOptions) {
+ return options.lookupProperty(this, 'testProperty');
+ })
+ .withInput({ testProperty: 'abc' })
+ .toCompileTo('abc');
+ });
+ });
+});
+
+function deleteAllKeys(obj: { [key: string]: any }) {
+ for (const key of Object.keys(obj)) {
+ delete obj[key];
+ }
+}
diff --git a/dev/lib/handlebars/src/spec/index.partials.test.ts b/dev/lib/handlebars/src/spec/index.partials.test.ts
new file mode 100644
index 00000000..65930d06
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.partials.test.ts
@@ -0,0 +1,591 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from '../..';
+import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench';
+
+describe('partials', () => {
+ it('basic partials', () => {
+ const string = 'Dudes: {{#dudes}}{{> dude}}{{/dudes}}';
+ const partial = '{{name}} ({{url}}) ';
+ const hash = {
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ };
+
+ expectTemplate(string)
+ .withInput(hash)
+ .withPartials({ dude: partial })
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+
+ expectTemplate(string)
+ .withInput(hash)
+ .withPartials({ dude: partial })
+ .withRuntimeOptions({ data: false })
+ .withCompileOptions({ data: false })
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+ });
+
+ it('dynamic partials', () => {
+ const string = 'Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}';
+ const partial = '{{name}} ({{url}}) ';
+ const hash = {
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ };
+ const helpers = {
+ partial: () => 'dude',
+ };
+
+ expectTemplate(string)
+ .withInput(hash)
+ .withHelpers(helpers)
+ .withPartials({ dude: partial })
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+
+ expectTemplate(string)
+ .withInput(hash)
+ .withHelpers(helpers)
+ .withPartials({ dude: partial })
+ .withRuntimeOptions({ data: false })
+ .withCompileOptions({ data: false })
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+ });
+
+ it('failing dynamic partials', () => {
+ expectTemplate('Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withHelper('partial', () => 'missing')
+ .withPartial('dude', '{{name}} ({{url}}) ')
+ .toThrow('The partial missing could not be found'); // TODO: Is there a way we can test that the error is of type `Handlebars.Exception`?
+ });
+
+ it('partials with context', () => {
+ expectTemplate('Dudes: {{>dude dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartial('dude', '{{#this}}{{name}} ({{url}}) {{/this}}')
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+ });
+
+ it('partials with no context', () => {
+ const partial = '{{name}} ({{url}}) ';
+ const hash = {
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ };
+
+ expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}')
+ .withInput(hash)
+ .withPartial('dude', partial)
+ .withCompileOptions({ explicitPartialContext: true })
+ .toCompileTo('Dudes: () () ');
+
+ expectTemplate('Dudes: {{#dudes}}{{>dude name="foo"}}{{/dudes}}')
+ .withInput(hash)
+ .withPartial('dude', partial)
+ .withCompileOptions({ explicitPartialContext: true })
+ .toCompileTo('Dudes: foo () foo () ');
+ });
+
+ it('partials with string context', () => {
+ expectTemplate('Dudes: {{>dude "dudes"}}')
+ .withPartial('dude', '{{.}}')
+ .toCompileTo('Dudes: dudes');
+ });
+
+ it('partials with undefined context', () => {
+ expectTemplate('Dudes: {{>dude dudes}}')
+ .withPartial('dude', '{{foo}} Empty')
+ .toCompileTo('Dudes: Empty');
+ });
+
+ it('partials with duplicate parameters', () => {
+ expectTemplate('Dudes: {{>dude dudes foo bar=baz}}').toThrow(
+ 'Unsupported number of partial arguments: 2 - 1:7'
+ );
+ });
+
+ it('partials with parameters', () => {
+ expectTemplate('Dudes: {{#dudes}}{{> dude others=..}}{{/dudes}}')
+ .withInput({
+ foo: 'bar',
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartial('dude', '{{others.foo}}{{name}} ({{url}}) ')
+ .toCompileTo('Dudes: barYehuda (http://yehuda) barAlan (http://alan) ');
+ });
+
+ it('partial in a partial', () => {
+ expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartials({
+ dude: '{{name}} {{> url}} ',
+ url: '<a href="{{url}}">{{url}}</a>',
+ })
+ .toCompileTo(
+ 'Dudes: Yehuda <a href="http://yehuda">http://yehuda</a> Alan <a href="http://alan">http://alan</a> '
+ );
+ });
+
+ it('rendering undefined partial throws an exception', () => {
+ expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found');
+ });
+
+ it('registering undefined partial throws an exception', () => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+
+ expect(() => {
+ kbnHandlebarsEnv!.registerPartial('undefined_test', undefined as any);
+ }).toThrow('Attempting to register a partial called "undefined_test" as undefined');
+
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('rendering template partial in vm mode throws an exception', () => {
+ expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found');
+ });
+
+ it('rendering function partial in vm mode', () => {
+ function partial(context: any) {
+ return context.name + ' (' + context.url + ') ';
+ }
+ expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartial('dude', partial)
+ .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) ');
+ });
+
+ it('GH-14: a partial preceding a selector', () => {
+ expectTemplate('Dudes: {{>dude}} {{anotherDude}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('dude', '{{name}}')
+ .toCompileTo('Dudes: Jeepers Creepers');
+ });
+
+ it('Partials with slash paths', () => {
+ expectTemplate('Dudes: {{> shared/dude}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('shared/dude', '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('Partials with slash and point paths', () => {
+ expectTemplate('Dudes: {{> shared/dude.thing}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('shared/dude.thing', '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('Global Partials', () => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+
+ kbnHandlebarsEnv!.registerPartial('globalTest', '{{anotherDude}}');
+
+ expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('shared/dude', '{{name}}')
+ .toCompileTo('Dudes: Jeepers Creepers');
+
+ kbnHandlebarsEnv!.unregisterPartial('globalTest');
+ expect(kbnHandlebarsEnv!.partials.globalTest).toBeUndefined();
+
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('Multiple partial registration', () => {
+ global.kbnHandlebarsEnv = Handlebars.create();
+
+ kbnHandlebarsEnv!.registerPartial({
+ 'shared/dude': '{{name}}',
+ globalTest: '{{anotherDude}}',
+ });
+
+ expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('notused', 'notused') // trick the test bench into running with partials enabled
+ .toCompileTo('Dudes: Jeepers Creepers');
+
+ global.kbnHandlebarsEnv = null;
+ });
+
+ it('Partials with integer path', () => {
+ expectTemplate('Dudes: {{> 404}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial(404, '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('Partials with complex path', () => {
+ expectTemplate('Dudes: {{> 404/asdf?.bar}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('404/asdf?.bar', '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('Partials with escaped', () => {
+ expectTemplate('Dudes: {{> [+404/asdf?.bar]}}')
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('+404/asdf?.bar', '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('Partials with string', () => {
+ expectTemplate("Dudes: {{> '+404/asdf?.bar'}}")
+ .withInput({ name: 'Jeepers', anotherDude: 'Creepers' })
+ .withPartial('+404/asdf?.bar', '{{name}}')
+ .toCompileTo('Dudes: Jeepers');
+ });
+
+ it('should handle empty partial', () => {
+ expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartial('dude', '')
+ .toCompileTo('Dudes: ');
+ });
+
+ // Skipping test as this only makes sense when there's no `compile` function (i.e. runtime-only mode).
+ // We do not support that mode with `@kbn/handlebars`, so there's no need to test it
+ it.skip('throw on missing partial', () => {
+ const handlebars = Handlebars.create();
+ (handlebars.compile as any) = undefined;
+ const template = handlebars.precompile('{{> dude}}');
+ const render = handlebars.template(eval('(' + template + ')')); // eslint-disable-line no-eval
+ expect(() => {
+ render(
+ {},
+ {
+ partials: {
+ dude: 'fail',
+ },
+ }
+ );
+ }).toThrow(/The partial dude could not be compiled/);
+ });
+
+ describe('partial blocks', () => {
+ it('should render partial block as default', () => {
+ expectTemplate('{{#> dude}}success{{/dude}}').toCompileTo('success');
+ });
+
+ it('should execute default block with proper context', () => {
+ expectTemplate('{{#> dude context}}{{value}}{{/dude}}')
+ .withInput({ context: { value: 'success' } })
+ .toCompileTo('success');
+ });
+
+ it('should propagate block parameters to default block', () => {
+ expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}')
+ .withInput({ context: { value: 'success' } })
+ .toCompileTo('success');
+ });
+
+ it('should not use partial block if partial exists', () => {
+ expectTemplate('{{#> dude}}fail{{/dude}}')
+ .withPartials({ dude: 'success' })
+ .toCompileTo('success');
+ });
+
+ it('should render block from partial', () => {
+ expectTemplate('{{#> dude}}success{{/dude}}')
+ .withPartials({ dude: '{{> @partial-block }}' })
+ .toCompileTo('success');
+ });
+
+ it('should be able to render the partial-block twice', () => {
+ expectTemplate('{{#> dude}}success{{/dude}}')
+ .withPartials({ dude: '{{> @partial-block }} {{> @partial-block }}' })
+ .toCompileTo('success success');
+ });
+
+ it('should render block from partial with context', () => {
+ expectTemplate('{{#> dude}}{{value}}{{/dude}}')
+ .withInput({ context: { value: 'success' } })
+ .withPartials({
+ dude: '{{#with context}}{{> @partial-block }}{{/with}}',
+ })
+ .toCompileTo('success');
+ });
+
+ it('should be able to access the @data frame from a partial-block', () => {
+ expectTemplate('{{#> dude}}in-block: {{@root/value}}{{/dude}}')
+ .withInput({ value: 'success' })
+ .withPartials({
+ dude: '<code>before-block: {{@root/value}} {{> @partial-block }}</code>',
+ })
+ .toCompileTo('<code>before-block: success in-block: success</code>');
+ });
+
+ it('should allow the #each-helper to be used along with partial-blocks', () => {
+ expectTemplate('<template>{{#> list value}}value = {{.}}{{/list}}</template>')
+ .withInput({
+ value: ['a', 'b', 'c'],
+ })
+ .withPartials({
+ list: '<list>{{#each .}}<item>{{> @partial-block}}</item>{{/each}}</list>',
+ })
+ .toCompileTo(
+ '<template><list><item>value = a</item><item>value = b</item><item>value = c</item></list></template>'
+ );
+ });
+
+ it('should render block from partial with context (twice)', () => {
+ expectTemplate('{{#> dude}}{{value}}{{/dude}}')
+ .withInput({ context: { value: 'success' } })
+ .withPartials({
+ dude: '{{#with context}}{{> @partial-block }} {{> @partial-block }}{{/with}}',
+ })
+ .toCompileTo('success success');
+ });
+
+ it('should render block from partial with context [2]', () => {
+ expectTemplate('{{#> dude}}{{../context/value}}{{/dude}}')
+ .withInput({ context: { value: 'success' } })
+ .withPartials({
+ dude: '{{#with context}}{{> @partial-block }}{{/with}}',
+ })
+ .toCompileTo('success');
+ });
+
+ it('should render block from partial with block params', () => {
+ expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}')
+ .withInput({ context: { value: 'success' } })
+ .withPartials({ dude: '{{> @partial-block }}' })
+ .toCompileTo('success');
+ });
+
+ it('should render nested partial blocks', () => {
+ expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
+ .withInput({ value: 'success' })
+ .withPartials({
+ outer:
+ '<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}</outer>',
+ nested: '<nested>{{> @partial-block}}</nested>',
+ })
+ .toCompileTo(
+ '<template><outer><nested><outer-block>success</outer-block></nested></outer></template>'
+ );
+ });
+
+ it('should render nested partial blocks at different nesting levels', () => {
+ expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
+ .withInput({ value: 'success' })
+ .withPartials({
+ outer:
+ '<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}</outer>',
+ nested: '<nested>{{> @partial-block}}</nested>',
+ })
+ .toCompileTo(
+ '<template><outer><nested><outer-block>success</outer-block></nested>success</outer></template>'
+ );
+ });
+
+ it('should render nested partial blocks at different nesting levels (twice)', () => {
+ expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
+ .withInput({ value: 'success' })
+ .withPartials({
+ outer:
+ '<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}+{{> @partial-block}}</outer>',
+ nested: '<nested>{{> @partial-block}}</nested>',
+ })
+ .toCompileTo(
+ '<template><outer><nested><outer-block>success success</outer-block></nested>success+success</outer></template>'
+ );
+ });
+
+ it('should render nested partial blocks (twice at each level)', () => {
+ expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
+ .withInput({ value: 'success' })
+ .withPartials({
+ outer:
+ '<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}</outer>',
+ nested: '<nested>{{> @partial-block}}{{> @partial-block}}</nested>',
+ })
+ .toCompileTo(
+ '<template><outer>' +
+ '<nested><outer-block>success success</outer-block><outer-block>success success</outer-block></nested>' +
+ '</outer></template>'
+ );
+ });
+ });
+
+ describe('inline partials', () => {
+ it('should define inline partials for template', () => {
+ expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}').toCompileTo(
+ 'success'
+ );
+ });
+
+ it('should overwrite multiple partials in the same template', () => {
+ expectTemplate(
+ '{{#*inline "myPartial"}}fail{{/inline}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}'
+ ).toCompileTo('success');
+ });
+
+ it('should define inline partials for block', () => {
+ expectTemplate(
+ '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}'
+ ).toCompileTo('success');
+
+ expectTemplate(
+ '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{/with}}{{> myPartial}}'
+ ).toThrow(/myPartial could not/);
+ });
+
+ it('should override global partials', () => {
+ expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}')
+ .withPartials({
+ myPartial: () => 'fail',
+ })
+ .toCompileTo('success');
+ });
+
+ it('should override template partials', () => {
+ expectTemplate(
+ '{{#*inline "myPartial"}}fail{{/inline}}{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}'
+ ).toCompileTo('success');
+ });
+
+ it('should override partials down the entire stack', () => {
+ expectTemplate(
+ '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{#with .}}{{#with .}}{{> myPartial}}{{/with}}{{/with}}{{/with}}'
+ ).toCompileTo('success');
+ });
+
+ it('should define inline partials for partial call', () => {
+ expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> dude}}')
+ .withPartials({ dude: '{{> myPartial }}' })
+ .toCompileTo('success');
+ });
+
+ it('should define inline partials in partial block call', () => {
+ expectTemplate('{{#> dude}}{{#*inline "myPartial"}}success{{/inline}}{{/dude}}')
+ .withPartials({ dude: '{{> myPartial }}' })
+ .toCompileTo('success');
+ });
+
+ it('should render nested inline partials', () => {
+ expectTemplate(
+ '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' +
+ '{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' +
+ '{{#>outer}}{{value}}{{/outer}}'
+ )
+ .withInput({ value: 'success' })
+ .toCompileTo('<inner><outer-block>success</outer-block></inner>');
+ });
+
+ it('should render nested inline partials with partial-blocks on different nesting levels', () => {
+ expectTemplate(
+ '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{>@partial-block}}{{/inline}}' +
+ '{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' +
+ '{{#>outer}}{{value}}{{/outer}}'
+ )
+ .withInput({ value: 'success' })
+ .toCompileTo('<inner><outer-block>success</outer-block></inner>success');
+ });
+
+ it('should render nested inline partials (twice at each level)', () => {
+ expectTemplate(
+ '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}} {{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' +
+ '{{#*inline "inner"}}<inner>{{>@partial-block}}{{>@partial-block}}</inner>{{/inline}}' +
+ '{{#>outer}}{{value}}{{/outer}}'
+ )
+ .withInput({ value: 'success' })
+ .toCompileTo(
+ '<inner><outer-block>success success</outer-block><outer-block>success success</outer-block></inner>'
+ );
+ });
+ });
+
+ forEachCompileFunctionName((compileName) => {
+ it(`should pass compiler flags for ${compileName} function`, () => {
+ const env = Handlebars.create();
+ env.registerPartial('partial', '{{foo}}');
+ const compile = env[compileName].bind(env);
+ const template = compile('{{foo}} {{> partial}}', { noEscape: true });
+ expect(template({ foo: '<' })).toEqual('< <');
+ });
+ });
+
+ describe('standalone partials', () => {
+ it('indented partials', () => {
+ expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartial('dude', '{{name}}\n')
+ .toCompileTo('Dudes:\n Yehuda\n Alan\n');
+ });
+
+ it('nested indented partials', () => {
+ expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartials({
+ dude: '{{name}}\n {{> url}}',
+ url: '{{url}}!\n',
+ })
+ .toCompileTo('Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n');
+ });
+
+ it('prevent nested indented partials', () => {
+ expectTemplate('Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}')
+ .withInput({
+ dudes: [
+ { name: 'Yehuda', url: 'http://yehuda' },
+ { name: 'Alan', url: 'http://alan' },
+ ],
+ })
+ .withPartials({
+ dude: '{{name}}\n {{> url}}',
+ url: '{{url}}!\n',
+ })
+ .withCompileOptions({ preventIndent: true })
+ .toCompileTo('Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.regressions.test.ts b/dev/lib/handlebars/src/spec/index.regressions.test.ts
new file mode 100644
index 00000000..fc2065fe
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.regressions.test.ts
@@ -0,0 +1,379 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, { type HelperOptions } from '../..';
+import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench';
+
+describe('Regressions', () => {
+ it('GH-94: Cannot read property of undefined', () => {
+ expectTemplate('{{#books}}{{title}}{{author.name}}{{/books}}')
+ .withInput({
+ books: [
+ {
+ title: 'The origin of species',
+ author: {
+ name: 'Charles Darwin',
+ },
+ },
+ {
+ title: 'Lazarillo de Tormes',
+ },
+ ],
+ })
+ .toCompileTo('The origin of speciesCharles DarwinLazarillo de Tormes');
+ });
+
+ it("GH-150: Inverted sections print when they shouldn't", () => {
+ const string = '{{^set}}not set{{/set}} :: {{#set}}set{{/set}}';
+ expectTemplate(string).toCompileTo('not set :: ');
+ expectTemplate(string).withInput({ set: undefined }).toCompileTo('not set :: ');
+ expectTemplate(string).withInput({ set: false }).toCompileTo('not set :: ');
+ expectTemplate(string).withInput({ set: true }).toCompileTo(' :: set');
+ });
+
+ it('GH-158: Using array index twice, breaks the template', () => {
+ expectTemplate('{{arr.[0]}}, {{arr.[1]}}')
+ .withInput({ arr: [1, 2] })
+ .toCompileTo('1, 2');
+ });
+
+ it("bug reported by @fat where lambdas weren't being properly resolved", () => {
+ const string =
+ '<strong>This is a slightly more complicated {{thing}}.</strong>.\n' +
+ '{{! Just ignore this business. }}\n' +
+ 'Check this out:\n' +
+ '{{#hasThings}}\n' +
+ '<ul>\n' +
+ '{{#things}}\n' +
+ '<li class={{className}}>{{word}}</li>\n' +
+ '{{/things}}</ul>.\n' +
+ '{{/hasThings}}\n' +
+ '{{^hasThings}}\n' +
+ '\n' +
+ '<small>Nothing to check out...</small>\n' +
+ '{{/hasThings}}';
+
+ const data = {
+ thing() {
+ return 'blah';
+ },
+ things: [
+ { className: 'one', word: '@fat' },
+ { className: 'two', word: '@dhg' },
+ { className: 'three', word: '@sayrer' },
+ ],
+ hasThings() {
+ return true;
+ },
+ };
+
+ const output =
+ '<strong>This is a slightly more complicated blah.</strong>.\n' +
+ 'Check this out:\n' +
+ '<ul>\n' +
+ '<li class=one>@fat</li>\n' +
+ '<li class=two>@dhg</li>\n' +
+ '<li class=three>@sayrer</li>\n' +
+ '</ul>.\n';
+
+ expectTemplate(string).withInput(data).toCompileTo(output);
+ });
+
+ it('GH-408: Multiple loops fail', () => {
+ expectTemplate('{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}')
+ .withInput([
+ { name: 'John Doe', location: { city: 'Chicago' } },
+ { name: 'Jane Doe', location: { city: 'New York' } },
+ ])
+ .toCompileTo('John DoeJane DoeJohn DoeJane DoeJohn DoeJane Doe');
+ });
+
+ it('GS-428: Nested if else rendering', () => {
+ const succeedingTemplate =
+ '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}} {{#blk}} Expected {{/blk}} {{/inverse}}';
+ const failingTemplate =
+ '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}} {{#blk}} Expected {{/blk}} {{/inverse}}';
+
+ const helpers = {
+ blk(block: HelperOptions) {
+ return block.fn('');
+ },
+ inverse(block: HelperOptions) {
+ return block.inverse('');
+ },
+ };
+
+ expectTemplate(succeedingTemplate).withHelpers(helpers).toCompileTo(' Expected ');
+ expectTemplate(failingTemplate).withHelpers(helpers).toCompileTo(' Expected ');
+ });
+
+ it('GH-458: Scoped this identifier', () => {
+ expectTemplate('{{./foo}}').withInput({ foo: 'bar' }).toCompileTo('bar');
+ });
+
+ it('GH-375: Unicode line terminators', () => {
+ expectTemplate('\u2028').toCompileTo('\u2028');
+ });
+
+ it('GH-534: Object prototype aliases', () => {
+ /* eslint-disable no-extend-native */
+ // @ts-expect-error
+ Object.prototype[0xd834] = true;
+
+ expectTemplate('{{foo}}').withInput({ foo: 'bar' }).toCompileTo('bar');
+
+ // @ts-expect-error
+ delete Object.prototype[0xd834];
+ /* eslint-enable no-extend-native */
+ });
+
+ it('GH-437: Matching escaping', () => {
+ expectTemplate('{{{a}}').toThrow(/Parse error on/);
+ expectTemplate('{{a}}}').toThrow(/Parse error on/);
+ });
+
+ it('GH-676: Using array in escaping mustache fails', () => {
+ const data = { arr: [1, 2] };
+ expectTemplate('{{arr}}').withInput(data).toCompileTo(data.arr.toString());
+ });
+
+ it('Mustache man page', () => {
+ expectTemplate(
+ 'Hello {{name}}. You have just won ${{value}}!{{#in_ca}} Well, ${{taxed_value}}, after taxes.{{/in_ca}}'
+ )
+ .withInput({
+ name: 'Chris',
+ value: 10000,
+ taxed_value: 10000 - 10000 * 0.4,
+ in_ca: true,
+ })
+ .toCompileTo('Hello Chris. You have just won $10000! Well, $6000, after taxes.');
+ });
+
+ it('GH-731: zero context rendering', () => {
+ expectTemplate('{{#foo}} This is {{bar}} ~ {{/foo}}')
+ .withInput({
+ foo: 0,
+ bar: 'OK',
+ })
+ .toCompileTo(' This is ~ ');
+ });
+
+ it('GH-820: zero pathed rendering', () => {
+ expectTemplate('{{foo.bar}}').withInput({ foo: 0 }).toCompileTo('');
+ });
+
+ it('GH-837: undefined values for helpers', () => {
+ expectTemplate('{{str bar.baz}}')
+ .withHelpers({
+ str(value) {
+ return value + '';
+ },
+ })
+ .toCompileTo('undefined');
+ });
+
+ it('GH-926: Depths and de-dupe', () => {
+ expectTemplate(
+ '{{#if dater}}{{#each data}}{{../name}}{{/each}}{{else}}{{#each notData}}{{../name}}{{/each}}{{/if}}'
+ )
+ .withInput({
+ name: 'foo',
+ data: [1],
+ notData: [1],
+ })
+ .toCompileTo('foo');
+ });
+
+ it('GH-1021: Each empty string key', () => {
+ expectTemplate('{{#each data}}Key: {{@key}}\n{{/each}}')
+ .withInput({
+ data: {
+ '': 'foo',
+ name: 'Chris',
+ value: 10000,
+ },
+ })
+ .toCompileTo('Key: \nKey: name\nKey: value\n');
+ });
+
+ it('GH-1054: Should handle simple safe string responses', () => {
+ expectTemplate('{{#wrap}}{{>partial}}{{/wrap}}')
+ .withHelpers({
+ wrap(options: HelperOptions) {
+ return new Handlebars.SafeString(options.fn());
+ },
+ })
+ .withPartials({
+ partial: '{{#wrap}}<partial>{{/wrap}}',
+ })
+ .toCompileTo('<partial>');
+ });
+
+ it('GH-1065: Sparse arrays', () => {
+ const array = [];
+ array[1] = 'foo';
+ array[3] = 'bar';
+ expectTemplate('{{#each array}}{{@index}}{{.}}{{/each}}')
+ .withInput({ array })
+ .toCompileTo('1foo3bar');
+ });
+
+ it('GH-1093: Undefined helper context', () => {
+ expectTemplate('{{#each obj}}{{{helper}}}{{.}}{{/each}}')
+ .withInput({ obj: { foo: undefined, bar: 'bat' } })
+ .withHelpers({
+ helper(this: any) {
+ // It's valid to execute a block against an undefined context, but
+ // helpers can not do so, so we expect to have an empty object here;
+ for (const name in this) {
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
+ return 'found';
+ }
+ }
+ // And to make IE happy, check for the known string as length is not enumerated.
+ return this === 'bat' ? 'found' : 'not';
+ },
+ })
+ .toCompileTo('notfoundbat');
+ });
+
+ it('should support multiple levels of inline partials', () => {
+ expectTemplate('{{#> layout}}{{#*inline "subcontent"}}subcontent{{/inline}}{{/layout}}')
+ .withPartials({
+ doctype: 'doctype{{> content}}',
+ layout: '{{#> doctype}}{{#*inline "content"}}layout{{> subcontent}}{{/inline}}{{/doctype}}',
+ })
+ .toCompileTo('doctypelayoutsubcontent');
+ });
+
+ it('GH-1089: should support failover content in multiple levels of inline partials', () => {
+ expectTemplate('{{#> layout}}{{/layout}}')
+ .withPartials({
+ doctype: 'doctype{{> content}}',
+ layout:
+ '{{#> doctype}}{{#*inline "content"}}layout{{#> subcontent}}subcontent{{/subcontent}}{{/inline}}{{/doctype}}',
+ })
+ .toCompileTo('doctypelayoutsubcontent');
+ });
+
+ it('GH-1099: should support greater than 3 nested levels of inline partials', () => {
+ expectTemplate('{{#> layout}}Outer{{/layout}}')
+ .withPartials({
+ layout: '{{#> inner}}Inner{{/inner}}{{> @partial-block }}',
+ inner: '',
+ })
+ .toCompileTo('Outer');
+ });
+
+ it('GH-1135 : Context handling within each iteration', () => {
+ expectTemplate(
+ '{{#each array}}\n' +
+ ' 1. IF: {{#if true}}{{../name}}-{{../../name}}-{{../../../name}}{{/if}}\n' +
+ ' 2. MYIF: {{#myif true}}{{../name}}={{../../name}}={{../../../name}}{{/myif}}\n' +
+ '{{/each}}'
+ )
+ .withInput({ array: [1], name: 'John' })
+ .withHelpers({
+ myif(conditional, options: HelperOptions) {
+ if (conditional) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
+ },
+ })
+ .toCompileTo(' 1. IF: John--\n' + ' 2. MYIF: John==\n');
+ });
+
+ it('GH-1186: Support block params for existing programs', () => {
+ expectTemplate(
+ '{{#*inline "test"}}{{> @partial-block }}{{/inline}}' +
+ '{{#>test }}{{#each listOne as |item|}}{{ item }}{{/each}}{{/test}}' +
+ '{{#>test }}{{#each listTwo as |item|}}{{ item }}{{/each}}{{/test}}'
+ )
+ .withInput({
+ listOne: ['a'],
+ listTwo: ['b'],
+ })
+ .toCompileTo('ab');
+ });
+
+ it('GH-1319: "unless" breaks when "each" value equals "null"', () => {
+ expectTemplate('{{#each list}}{{#unless ./prop}}parent={{../value}} {{/unless}}{{/each}}')
+ .withInput({
+ value: 'parent',
+ list: [null, 'a'],
+ })
+ .toCompileTo('parent=parent parent=parent ');
+ });
+
+ it('GH-1341: 4.0.7 release breaks {{#if @partial-block}} usage', () => {
+ expectTemplate('template {{>partial}} template')
+ .withPartials({
+ partialWithBlock: '{{#if @partial-block}} block {{> @partial-block}} block {{/if}}',
+ partial: '{{#> partialWithBlock}} partial {{/partialWithBlock}}',
+ })
+ .toCompileTo('template block partial block template');
+ });
+
+ it('should allow hash with protected array names', () => {
+ expectTemplate('{{helpa length="foo"}}')
+ .withInput({ array: [1], name: 'John' })
+ .withHelpers({
+ helpa(options: HelperOptions) {
+ return options.hash.length;
+ },
+ })
+ .toCompileTo('foo');
+ });
+
+ describe('GH-1598: Performance degradation for partials since v4.3.0', () => {
+ let newHandlebarsInstance: typeof Handlebars;
+ let spy: jest.SpyInstance;
+ beforeEach(() => {
+ newHandlebarsInstance = Handlebars.create();
+ });
+ afterEach(() => {
+ spy.mockRestore();
+ });
+
+ forEachCompileFunctionName((compileName) => {
+ it(`should only compile global partials once when calling #${compileName}`, () => {
+ const compile = newHandlebarsInstance[compileName].bind(newHandlebarsInstance);
+ let calls;
+ switch (compileName) {
+ case 'compile':
+ spy = jest.spyOn(newHandlebarsInstance, 'template');
+ calls = 3;
+ break;
+ case 'compileAST':
+ spy = jest.spyOn(newHandlebarsInstance, 'compileAST');
+ calls = 2;
+ break;
+ }
+ newHandlebarsInstance.registerPartial({
+ dude: 'I am a partial',
+ });
+ const string = 'Dudes: {{> dude}} {{> dude}}';
+ compile(string)(); // This should compile template + partial once
+ compile(string)(); // This should only compile template
+ expect(spy).toHaveBeenCalledTimes(calls);
+ spy.mockRestore();
+ });
+ });
+ });
+
+ describe("GH-1639: TypeError: Cannot read property 'apply' of undefined\" when handlebars version > 4.6.0 (undocumented, deprecated usage)", () => {
+ it('should treat undefined helpers like non-existing helpers', () => {
+ expectTemplate('{{foo}}')
+ .withHelper('foo', undefined as any)
+ .withInput({ foo: 'bar' })
+ .toCompileTo('bar');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.security.test.ts b/dev/lib/handlebars/src/spec/index.security.test.ts
new file mode 100644
index 00000000..878a0931
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.security.test.ts
@@ -0,0 +1,132 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('security issues', () => {
+ describe('GH-1495: Prevent Remote Code Execution via constructor', () => {
+ it('should not allow constructors to be accessed', () => {
+ expectTemplate('{{lookup (lookup this "constructor") "name"}}').withInput({}).toCompileTo('');
+ expectTemplate('{{constructor.name}}').withInput({}).toCompileTo('');
+ });
+
+ it('GH-1603: should not allow constructors to be accessed (lookup via toString)', () => {
+ expectTemplate('{{lookup (lookup this (list "constructor")) "name"}}')
+ .withInput({})
+ .withHelper('list', function (element) {
+ return [element];
+ })
+ .toCompileTo('');
+ });
+
+ it('should allow the "constructor" property to be accessed if it is an "ownProperty"', () => {
+ expectTemplate('{{constructor.name}}')
+ .withInput({ constructor: { name: 'here we go' } })
+ .toCompileTo('here we go');
+
+ expectTemplate('{{lookup (lookup this "constructor") "name"}}')
+ .withInput({ constructor: { name: 'here we go' } })
+ .toCompileTo('here we go');
+ });
+
+ it('should allow the "constructor" property to be accessed if it is an "own property"', () => {
+ expectTemplate('{{lookup (lookup this "constructor") "name"}}')
+ .withInput({ constructor: { name: 'here we go' } })
+ .toCompileTo('here we go');
+ });
+ });
+
+ describe('GH-1558: Prevent explicit call of helperMissing-helpers', () => {
+ describe('without the option "allowExplicitCallOfHelperMissing"', () => {
+ it('should throw an exception when calling "{{helperMissing}}" ', () => {
+ expectTemplate('{{helperMissing}}').toThrow(Error);
+ });
+
+ it('should throw an exception when calling "{{#helperMissing}}{{/helperMissing}}" ', () => {
+ expectTemplate('{{#helperMissing}}{{/helperMissing}}').toThrow(Error);
+ });
+
+ it('should throw an exception when calling "{{blockHelperMissing "abc" .}}" ', () => {
+ const functionCalls = [];
+ expect(() => {
+ const template = Handlebars.compile('{{blockHelperMissing "abc" .}}');
+ template({
+ fn() {
+ functionCalls.push('called');
+ },
+ });
+ }).toThrow(Error);
+ expect(functionCalls.length).toEqual(0);
+ });
+
+ it('should throw an exception when calling "{{#blockHelperMissing .}}{{/blockHelperMissing}}"', () => {
+ expectTemplate('{{#blockHelperMissing .}}{{/blockHelperMissing}}')
+ .withInput({
+ fn() {
+ return 'functionInData';
+ },
+ })
+ .toThrow(Error);
+ });
+ });
+ });
+
+ describe('GH-1563', () => {
+ it('should not allow to access constructor after overriding via __defineGetter__', () => {
+ // @ts-expect-error
+ if ({}.__defineGetter__ == null || {}.__lookupGetter__ == null) {
+ return; // Browser does not support this exploit anyway
+ }
+ expectTemplate(
+ '{{__defineGetter__ "undefined" valueOf }}' +
+ '{{#with __lookupGetter__ }}' +
+ '{{__defineGetter__ "propertyIsEnumerable" (this.bind (this.bind 1)) }}' +
+ '{{constructor.name}}' +
+ '{{/with}}'
+ )
+ .withInput({})
+ .toThrow(/Missing helper: "__defineGetter__"/);
+ });
+ });
+
+ describe('GH-1595: dangerous properties', () => {
+ const templates = [
+ '{{constructor}}',
+ '{{__defineGetter__}}',
+ '{{__defineSetter__}}',
+ '{{__lookupGetter__}}',
+ '{{__proto__}}',
+ '{{lookup this "constructor"}}',
+ '{{lookup this "__defineGetter__"}}',
+ '{{lookup this "__defineSetter__"}}',
+ '{{lookup this "__lookupGetter__"}}',
+ '{{lookup this "__proto__"}}',
+ ];
+
+ templates.forEach((template) => {
+ describe('access should be denied to ' + template, () => {
+ it('by default', () => {
+ expectTemplate(template).withInput({}).toCompileTo('');
+ });
+ });
+ });
+ });
+
+ describe('escapes template variables', () => {
+ it('in default mode', () => {
+ expectTemplate("{{'a\\b'}}").withCompileOptions().withInput({ 'a\\b': 'c' }).toCompileTo('c');
+ });
+
+ it('in strict mode', () => {
+ expectTemplate("{{'a\\b'}}")
+ .withCompileOptions({ strict: true })
+ .withInput({ 'a\\b': 'c' })
+ .toCompileTo('c');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.strict.test.ts b/dev/lib/handlebars/src/spec/index.strict.test.ts
new file mode 100644
index 00000000..a8f294b9
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.strict.test.ts
@@ -0,0 +1,164 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('strict', () => {
+ describe('strict mode', () => {
+ it('should error on missing property lookup', () => {
+ expectTemplate('{{hello}}')
+ .withCompileOptions({ strict: true })
+ .toThrow(/"hello" not defined in/);
+ });
+
+ it('should error on missing child', () => {
+ expectTemplate('{{hello.bar}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ hello: { bar: 'foo' } })
+ .toCompileTo('foo');
+
+ expectTemplate('{{hello.bar}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ hello: {} })
+ .toThrow(/"bar" not defined in/);
+ });
+
+ it('should handle explicit undefined', () => {
+ expectTemplate('{{hello.bar}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ hello: { bar: undefined } })
+ .toCompileTo('');
+ });
+
+ it('should error on missing property lookup in known helpers mode', () => {
+ expectTemplate('{{hello}}')
+ .withCompileOptions({
+ strict: true,
+ knownHelpersOnly: true,
+ })
+ .toThrow(/"hello" not defined in/);
+ });
+
+ it('should error on missing context', () => {
+ expectTemplate('{{hello}}').withCompileOptions({ strict: true }).toThrow(Error);
+ });
+
+ it('should error on missing data lookup', () => {
+ const xt = expectTemplate('{{@hello}}').withCompileOptions({
+ strict: true,
+ });
+
+ xt.toThrow(Error);
+
+ xt.withRuntimeOptions({ data: { hello: 'foo' } }).toCompileTo('foo');
+ });
+
+ it('should not run helperMissing for helper calls', () => {
+ expectTemplate('{{hello foo}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ foo: true })
+ .toThrow(/"hello" not defined in/);
+
+ expectTemplate('{{#hello foo}}{{/hello}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ foo: true })
+ .toThrow(/"hello" not defined in/);
+ });
+
+ it('should throw on ambiguous blocks', () => {
+ expectTemplate('{{#hello}}{{/hello}}')
+ .withCompileOptions({ strict: true })
+ .toThrow(/"hello" not defined in/);
+
+ expectTemplate('{{^hello}}{{/hello}}')
+ .withCompileOptions({ strict: true })
+ .toThrow(/"hello" not defined in/);
+
+ expectTemplate('{{#hello.bar}}{{/hello.bar}}')
+ .withCompileOptions({ strict: true })
+ .withInput({ hello: {} })
+ .toThrow(/"bar" not defined in/);
+ });
+
+ it('should allow undefined parameters when passed to helpers', () => {
+ expectTemplate('{{#unless foo}}success{{/unless}}')
+ .withCompileOptions({ strict: true })
+ .toCompileTo('success');
+ });
+
+ it('should allow undefined hash when passed to helpers', () => {
+ expectTemplate('{{helper value=@foo}}')
+ .withCompileOptions({
+ strict: true,
+ })
+ .withHelpers({
+ helper(options) {
+ expect('value' in options.hash).toEqual(true);
+ expect(options.hash.value).toBeUndefined();
+ return 'success';
+ },
+ })
+ .toCompileTo('success');
+ });
+
+ it('should show error location on missing property lookup', () => {
+ expectTemplate('\n\n\n {{hello}}')
+ .withCompileOptions({ strict: true })
+ .toThrow('"hello" not defined in [object Object] - 4:5');
+ });
+
+ it('should error contains correct location properties on missing property lookup', () => {
+ try {
+ expectTemplate('\n\n\n {{hello}}')
+ .withCompileOptions({ strict: true })
+ .toCompileTo('throw before asserting this');
+ } catch (error) {
+ expect(error.lineNumber).toEqual(4);
+ expect(error.endLineNumber).toEqual(4);
+ expect(error.column).toEqual(5);
+ expect(error.endColumn).toEqual(10);
+ }
+ });
+ });
+
+ describe('assume objects', () => {
+ it('should ignore missing property', () => {
+ expectTemplate('{{hello}}').withCompileOptions({ assumeObjects: true }).toCompileTo('');
+ });
+
+ it('should ignore missing child', () => {
+ expectTemplate('{{hello.bar}}')
+ .withCompileOptions({ assumeObjects: true })
+ .withInput({ hello: {} })
+ .toCompileTo('');
+ });
+
+ it('should error on missing object', () => {
+ expectTemplate('{{hello.bar}}').withCompileOptions({ assumeObjects: true }).toThrow(Error);
+ });
+
+ it('should error on missing context', () => {
+ expectTemplate('{{hello}}')
+ .withCompileOptions({ assumeObjects: true })
+ .withInput(undefined)
+ .toThrow(Error);
+ });
+
+ it('should error on missing data lookup', () => {
+ expectTemplate('{{@hello.bar}}')
+ .withCompileOptions({ assumeObjects: true })
+ .withInput(undefined)
+ .toThrow(Error);
+ });
+
+ it('should execute blockHelperMissing', () => {
+ expectTemplate('{{^hello}}foo{{/hello}}')
+ .withCompileOptions({ assumeObjects: true })
+ .toCompileTo('foo');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.subexpressions.test.ts b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts
new file mode 100644
index 00000000..4dee24b7
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts
@@ -0,0 +1,214 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars, { type HelperOptions } from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('subexpressions', () => {
+ it('arg-less helper', () => {
+ expectTemplate('{{foo (bar)}}!')
+ .withHelpers({
+ foo(val) {
+ return val + val;
+ },
+ bar() {
+ return 'LOL';
+ },
+ })
+ .toCompileTo('LOLLOL!');
+ });
+
+ it('helper w args', () => {
+ expectTemplate('{{blog (equal a b)}}')
+ .withInput({ bar: 'LOL' })
+ .withHelpers({
+ blog(val) {
+ return 'val is ' + val;
+ },
+ equal(x, y) {
+ return x === y;
+ },
+ })
+ .toCompileTo('val is true');
+ });
+
+ it('mixed paths and helpers', () => {
+ expectTemplate('{{blog baz.bat (equal a b) baz.bar}}')
+ .withInput({ bar: 'LOL', baz: { bat: 'foo!', bar: 'bar!' } })
+ .withHelpers({
+ blog(val, that, theOther) {
+ return 'val is ' + val + ', ' + that + ' and ' + theOther;
+ },
+ equal(x, y) {
+ return x === y;
+ },
+ })
+ .toCompileTo('val is foo!, true and bar!');
+ });
+
+ it('supports much nesting', () => {
+ expectTemplate('{{blog (equal (equal true true) true)}}')
+ .withInput({ bar: 'LOL' })
+ .withHelpers({
+ blog(val) {
+ return 'val is ' + val;
+ },
+ equal(x, y) {
+ return x === y;
+ },
+ })
+ .toCompileTo('val is true');
+ });
+
+ it('GH-800 : Complex subexpressions', () => {
+ const context = { a: 'a', b: 'b', c: { c: 'c' }, d: 'd', e: { e: 'e' } };
+ const helpers = {
+ dash(a: any, b: any) {
+ return a + '-' + b;
+ },
+ concat(a: any, b: any) {
+ return a + b;
+ },
+ };
+
+ expectTemplate("{{dash 'abc' (concat a b)}}")
+ .withInput(context)
+ .withHelpers(helpers)
+ .toCompileTo('abc-ab');
+
+ expectTemplate('{{dash d (concat a b)}}')
+ .withInput(context)
+ .withHelpers(helpers)
+ .toCompileTo('d-ab');
+
+ expectTemplate('{{dash c.c (concat a b)}}')
+ .withInput(context)
+ .withHelpers(helpers)
+ .toCompileTo('c-ab');
+
+ expectTemplate('{{dash (concat a b) c.c}}')
+ .withInput(context)
+ .withHelpers(helpers)
+ .toCompileTo('ab-c');
+
+ expectTemplate('{{dash (concat a e.e) c.c}}')
+ .withInput(context)
+ .withHelpers(helpers)
+ .toCompileTo('ae-c');
+ });
+
+ it('provides each nested helper invocation its own options hash', () => {
+ let lastOptions: HelperOptions;
+ const helpers = {
+ equal(x: any, y: any, options: HelperOptions) {
+ if (!options || options === lastOptions) {
+ throw new Error('options hash was reused');
+ }
+ lastOptions = options;
+ return x === y;
+ },
+ };
+ expectTemplate('{{equal (equal true true) true}}').withHelpers(helpers).toCompileTo('true');
+ });
+
+ it('with hashes', () => {
+ expectTemplate("{{blog (equal (equal true true) true fun='yes')}}")
+ .withInput({ bar: 'LOL' })
+ .withHelpers({
+ blog(val) {
+ return 'val is ' + val;
+ },
+ equal(x, y) {
+ return x === y;
+ },
+ })
+ .toCompileTo('val is true');
+ });
+
+ it('as hashes', () => {
+ expectTemplate("{{blog fun=(equal (blog fun=1) 'val is 1')}}")
+ .withHelpers({
+ blog(options) {
+ return 'val is ' + options.hash.fun;
+ },
+ equal(x, y) {
+ return x === y;
+ },
+ })
+ .toCompileTo('val is true');
+ });
+
+ it('multiple subexpressions in a hash', () => {
+ expectTemplate('{{input aria-label=(t "Name") placeholder=(t "Example User")}}')
+ .withHelpers({
+ input(options) {
+ const hash = options.hash;
+ const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']);
+ const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder);
+ return new Handlebars.SafeString(
+ '<input aria-label="' + ariaLabel + '" placeholder="' + placeholder + '" />'
+ );
+ },
+ t(defaultString) {
+ return new Handlebars.SafeString(defaultString);
+ },
+ })
+ .toCompileTo('<input aria-label="Name" placeholder="Example User" />');
+ });
+
+ it('multiple subexpressions in a hash with context', () => {
+ expectTemplate('{{input aria-label=(t item.field) placeholder=(t item.placeholder)}}')
+ .withInput({
+ item: {
+ field: 'Name',
+ placeholder: 'Example User',
+ },
+ })
+ .withHelpers({
+ input(options) {
+ const hash = options.hash;
+ const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']);
+ const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder);
+ return new Handlebars.SafeString(
+ '<input aria-label="' + ariaLabel + '" placeholder="' + placeholder + '" />'
+ );
+ },
+ t(defaultString) {
+ return new Handlebars.SafeString(defaultString);
+ },
+ })
+ .toCompileTo('<input aria-label="Name" placeholder="Example User" />');
+ });
+
+ it('subexpression functions on the context', () => {
+ expectTemplate('{{foo (bar)}}!')
+ .withInput({
+ bar() {
+ return 'LOL';
+ },
+ })
+ .withHelpers({
+ foo(val) {
+ return val + val;
+ },
+ })
+ .toCompileTo('LOLLOL!');
+ });
+
+ it("subexpressions can't just be property lookups", () => {
+ expectTemplate('{{foo (bar)}}!')
+ .withInput({
+ bar: 'LOL',
+ })
+ .withHelpers({
+ foo(val) {
+ return val + val;
+ },
+ })
+ .toThrow();
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.utils.test.ts b/dev/lib/handlebars/src/spec/index.utils.test.ts
new file mode 100644
index 00000000..6350bc7c
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.utils.test.ts
@@ -0,0 +1,24 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from '../..';
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('utils', function () {
+ describe('#SafeString', function () {
+ it('constructing a safestring from a string and checking its type', function () {
+ const safe = new Handlebars.SafeString('testing 1, 2, 3');
+ expect(safe).toBeInstanceOf(Handlebars.SafeString);
+ expect(safe.toString()).toEqual('testing 1, 2, 3');
+ });
+
+ it('it should not escape SafeString properties', function () {
+ const name = new Handlebars.SafeString('<em>Sean O&#x27;Malley</em>');
+ expectTemplate('{{name}}').withInput({ name }).toCompileTo('<em>Sean O&#x27;Malley</em>');
+ });
+ });
+});
diff --git a/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts
new file mode 100644
index 00000000..1f7cf019
--- /dev/null
+++ b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts
@@ -0,0 +1,88 @@
+/*
+ * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js),
+ * and may include modifications made by Elasticsearch B.V.
+ * Elasticsearch B.V. licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import { expectTemplate } from '../__jest__/test_bench';
+
+describe('whitespace control', () => {
+ it('should strip whitespace around mustache calls', () => {
+ const hash = { foo: 'bar<' };
+ expectTemplate(' {{~foo~}} ').withInput(hash).toCompileTo('bar&lt;');
+ expectTemplate(' {{~foo}} ').withInput(hash).toCompileTo('bar&lt; ');
+ expectTemplate(' {{foo~}} ').withInput(hash).toCompileTo(' bar&lt;');
+ expectTemplate(' {{~&foo~}} ').withInput(hash).toCompileTo('bar<');
+ expectTemplate(' {{~{foo}~}} ').withInput(hash).toCompileTo('bar<');
+ expectTemplate('1\n{{foo~}} \n\n 23\n{{bar}}4').toCompileTo('1\n23\n4');
+ });
+
+ describe('blocks', () => {
+ it('should strip whitespace around simple block calls', () => {
+ const hash = { foo: 'bar<' };
+
+ expectTemplate(' {{~#if foo~}} bar {{~/if~}} ').withInput(hash).toCompileTo('bar');
+ expectTemplate(' {{#if foo~}} bar {{/if~}} ').withInput(hash).toCompileTo(' bar ');
+ expectTemplate(' {{~#if foo}} bar {{~/if}} ').withInput(hash).toCompileTo(' bar ');
+ expectTemplate(' {{#if foo}} bar {{/if}} ').withInput(hash).toCompileTo(' bar ');
+
+ expectTemplate(' \n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\n ')
+ .withInput(hash)
+ .toCompileTo('bar');
+
+ expectTemplate(' a\n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\na ')
+ .withInput(hash)
+ .toCompileTo(' abara ');
+ });
+
+ it('should strip whitespace around inverse block calls', () => {
+ expectTemplate(' {{~^if foo~}} bar {{~/if~}} ').toCompileTo('bar');
+ expectTemplate(' {{^if foo~}} bar {{/if~}} ').toCompileTo(' bar ');
+ expectTemplate(' {{~^if foo}} bar {{~/if}} ').toCompileTo(' bar ');
+ expectTemplate(' {{^if foo}} bar {{/if}} ').toCompileTo(' bar ');
+ expectTemplate(' \n\n{{~^if foo~}} \n\nbar \n\n{{~/if~}}\n\n ').toCompileTo('bar');
+ });
+
+ it('should strip whitespace around complex block calls', () => {
+ const hash = { foo: 'bar<' };
+
+ expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo('bar');
+ expectTemplate('{{#if foo~}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo('bar ');
+ expectTemplate('{{#if foo}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo(' bar');
+ expectTemplate('{{#if foo}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo(' bar ');
+ expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').withInput(hash).toCompileTo('bar');
+
+ expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n')
+ .withInput(hash)
+ .toCompileTo('bar');
+
+ expectTemplate('\n\n{{~#if foo~}} \n\n{{{foo}}} \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n')
+ .withInput(hash)
+ .toCompileTo('bar<');
+
+ expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').toCompileTo('baz');
+ expectTemplate('{{#if foo}} bar {{~^~}} baz {{/if}}').toCompileTo('baz ');
+ expectTemplate('{{#if foo~}} bar {{~^}} baz {{~/if}}').toCompileTo(' baz');
+ expectTemplate('{{#if foo~}} bar {{~^}} baz {{/if}}').toCompileTo(' baz ');
+ expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').toCompileTo('baz');
+ expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n').toCompileTo(
+ 'baz'
+ );
+ });
+ });
+
+ it('should strip whitespace around partials', () => {
+ expectTemplate('foo {{~> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar');
+ expectTemplate('foo {{> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar');
+ expectTemplate('foo {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar ');
+ expectTemplate('foo\n {{~> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar');
+ expectTemplate('foo\n {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo\n bar');
+ });
+
+ it('should only strip whitespace once', () => {
+ expectTemplate(' {{~foo~}} {{foo}} {{foo}} ')
+ .withInput({ foo: 'bar' })
+ .toCompileTo('barbar bar ');
+ });
+});
diff --git a/dev/lib/handlebars/src/symbols.ts b/dev/lib/handlebars/src/symbols.ts
new file mode 100644
index 00000000..85a8f2f3
--- /dev/null
+++ b/dev/lib/handlebars/src/symbols.ts
@@ -0,0 +1,8 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+export const kHelper = Symbol('helper');
+export const kAmbiguous = Symbol('ambiguous');
+export const kSimple = Symbol('simple');
diff --git a/dev/lib/handlebars/src/types.ts b/dev/lib/handlebars/src/types.ts
new file mode 100644
index 00000000..583170cb
--- /dev/null
+++ b/dev/lib/handlebars/src/types.ts
@@ -0,0 +1,225 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import { kHelper, kAmbiguous, kSimple } from './symbols';
+
+// Unexported `CompileOptions` lifted from node_modules/handlebars/types/index.d.ts
+// While it could also be extracted using `NonNullable<Parameters<typeof Handlebars.compile>[1]>`, this isn't possible since we declare the handlebars module below
+interface HandlebarsCompileOptions {
+ data?: boolean;
+ compat?: boolean;
+ knownHelpers?: KnownHelpers;
+ knownHelpersOnly?: boolean;
+ noEscape?: boolean;
+ strict?: boolean;
+ assumeObjects?: boolean;
+ preventIndent?: boolean;
+ ignoreStandalone?: boolean;
+ explicitPartialContext?: boolean;
+}
+
+/**
+ * A custom version of the Handlebars module with an extra `compileAST` function and fixed typings.
+ */
+declare module 'handlebars' {
+ /**
+ * Compiles the given Handlebars template without the use of `eval`.
+ *
+ * @returns A render function with the same API as the return value from the regular Handlebars `compile` function.
+ */
+ export function compileAST(
+ input: string | hbs.AST.Program,
+ options?: CompileOptions
+ ): TemplateDelegateFixed;
+
+ // --------------------------------------------------------
+ // Override/Extend inherited funcions and interfaces below that are incorrect.
+ //
+ // Any exported `const` or `type` types can't be overwritten, so we'll just
+ // have to live with those and cast them to the correct types in our code.
+ // Some of these fixed types, we'll instead export outside the scope of this
+ // 'handlebars' module so consumers of @kbn/handlebars at least have a way to
+ // access the correct types.
+ // --------------------------------------------------------
+
+ /**
+ * A {@link https://handlebarsjs.com/api-reference/helpers.html helper-function} type.
+ *
+ * When registering a helper function, it should be of this type.
+ */
+ export interface HelperDelegate extends HelperDelegateFixed {} // eslint-disable-line @typescript-eslint/no-empty-interface
+
+ /**
+ * A template-function type.
+ *
+ * This type is primarily used for the return value of by calls to
+ * {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-compile-template-options Handlebars.compile},
+ * Handlebars.compileAST and {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-precompile-template-options Handlebars.template}.
+ */
+ export interface TemplateDelegate<T = any> extends TemplateDelegateFixed<T> {} // eslint-disable-line @typescript-eslint/no-empty-interface
+
+ /**
+ * Register one or more {@link https://handlebarsjs.com/api-reference/runtime.html#handlebars-registerpartial-name-partial partials}.
+ *
+ * @param spec A key/value object where each key is the name of a partial (a string) and each value is the partial (either a string or a partial function).
+ */
+ export function registerPartial(spec: Record<string, TemplateFixed>): void; // Ensure `spec` object values can be strings
+}
+
+/**
+ * Supported Handlebars compile options.
+ *
+ * This is a subset of all the compile options supported by the upstream
+ * Handlebars module.
+ */
+export type CompileOptions = Pick<
+ HandlebarsCompileOptions,
+ | 'data'
+ | 'knownHelpers'
+ | 'knownHelpersOnly'
+ | 'noEscape'
+ | 'strict'
+ | 'assumeObjects'
+ | 'preventIndent'
+ | 'explicitPartialContext'
+>;
+
+/**
+ * Supported Handlebars runtime options
+ *
+ * This is a subset of all the runtime options supported by the upstream
+ * Handlebars module.
+ */
+export interface RuntimeOptions extends Pick<Handlebars.RuntimeOptions, 'data' | 'blockParams'> {
+ // The upstream `helpers` property is too loose and allows all functions.
+ helpers?: HelpersHash;
+ // The upstream `partials` property is incorrectly typed and doesn't allow
+ // partials to be strings.
+ partials?: PartialsHash;
+ // The upstream `decorators` property is too loose and allows all functions.
+ decorators?: DecoratorsHash;
+}
+
+/**
+ * The last argument being passed to a helper function is a an {@link https://handlebarsjs.com/api-reference/helpers.html#the-options-parameter options object}.
+ */
+export interface HelperOptions extends Omit<Handlebars.HelperOptions, 'fn' | 'inverse'> {
+ name: string;
+ fn: TemplateDelegateFixed;
+ inverse: TemplateDelegateFixed;
+ loc: { start: hbs.AST.SourceLocation['start']; end: hbs.AST.SourceLocation['end'] };
+ lookupProperty: LookupProperty;
+}
+
+// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above
+/**
+ * A {@link https://handlebarsjs.com/api-reference/helpers.html helper-function} type.
+ *
+ * When registering a helper function, it should be of this type.
+ */
+interface HelperDelegateFixed {
+ // eslint-disable-next-line @typescript-eslint/prefer-function-type
+ (...params: any[]): any;
+}
+export type { HelperDelegateFixed as HelperDelegate };
+
+// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above
+/**
+ * A template-function type.
+ *
+ * This type is primarily used for the return value of by calls to
+ * {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-compile-template-options Handlebars.compile},
+ * Handlebars.compileAST and {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-precompile-template-options Handlebars.template}.
+ */
+interface TemplateDelegateFixed<T = any> {
+ (context?: T, options?: RuntimeOptions): string; // Override to ensure `context` is optional
+ blockParams?: number; // TODO: Can this really be optional?
+ partials?: PartialsHash;
+}
+export type { TemplateDelegateFixed as TemplateDelegate };
+
+// According to the decorator docs
+// (https://github.com/handlebars-lang/handlebars.js/blob/4.x/docs/decorators-api.md)
+// a decorator will be called with a different set of arugments than what's
+// actually happening in the upstream code. So here I assume that the docs are
+// wrong and that the upstream code is correct. In reality, `context` is the
+// last 4 documented arguments rolled into one object.
+/**
+ * A {@link https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md decorator-function} type.
+ *
+ * When registering a decorator function, it should be of this type.
+ */
+export type DecoratorDelegate = (
+ prog: TemplateDelegateFixed,
+ props: Record<string, any>,
+ container: Container,
+ options: any
+) => any;
+
+// -----------------------------------------------------------------------------
+// INTERNAL TYPES
+// -----------------------------------------------------------------------------
+
+export type NodeType = typeof kHelper | typeof kAmbiguous | typeof kSimple;
+
+type LookupProperty = <T = any>(parent: Record<string, any>, propertyName: string) => T;
+
+export type NonBlockHelperOptions = Omit<HelperOptions, 'fn' | 'inverse'>;
+export type AmbiguousHelperOptions = HelperOptions | NonBlockHelperOptions;
+
+export type ProcessableStatementNode =
+ | hbs.AST.MustacheStatement
+ | hbs.AST.PartialStatement
+ | hbs.AST.SubExpression;
+export type ProcessableBlockStatementNode = hbs.AST.BlockStatement | hbs.AST.PartialBlockStatement;
+export type ProcessableNode = ProcessableStatementNode | ProcessableBlockStatementNode;
+export type ProcessableNodeWithPathParts = ProcessableNode & { path: hbs.AST.PathExpression };
+export type ProcessableNodeWithPathPartsOrLiteral = ProcessableNode & {
+ path: hbs.AST.PathExpression | hbs.AST.Literal;
+};
+
+export type HelpersHash = Record<string, HelperDelegateFixed>;
+export type PartialsHash = Record<string, TemplateFixed>;
+export type DecoratorsHash = Record<string, DecoratorDelegate>;
+
+// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above
+type TemplateFixed = TemplateDelegateFixed | string;
+export type { TemplateFixed as Template };
+
+export interface DecoratorOptions extends Omit<HelperOptions, 'lookupProperties'> {
+ args?: any[];
+}
+
+export interface VisitorHelper {
+ fn?: HelperDelegateFixed;
+ context: any[];
+ params: any[];
+ options: AmbiguousHelperOptions;
+}
+
+export interface ResolvePartialOptions
+ extends Omit<Handlebars.ResolvePartialOptions, 'helpers' | 'partials' | 'decorators'> {
+ // The upstream `helpers` property is too loose and allows all functions.
+ helpers?: HelpersHash;
+ // The upstream `partials` property is incorrectly typed and doesn't allow
+ // partials to be strings.
+ partials?: PartialsHash;
+ // The upstream `decorators` property is too loose and allows all functions.
+ decorators?: DecoratorsHash;
+}
+
+export interface Container {
+ helpers: HelpersHash;
+ partials: PartialsHash;
+ decorators: DecoratorsHash;
+ strict: (obj: Record<string, any>, name: string, loc: hbs.AST.SourceLocation) => any;
+ lookupProperty: LookupProperty;
+ lambda: (current: any, context: any) => any;
+ data: (value: any, depth: number) => any;
+ hooks: {
+ helperMissing?: HelperDelegateFixed;
+ blockHelperMissing?: HelperDelegateFixed;
+ };
+}
diff --git a/dev/lib/handlebars/src/utils.ts b/dev/lib/handlebars/src/utils.ts
new file mode 100644
index 00000000..f55bd98a
--- /dev/null
+++ b/dev/lib/handlebars/src/utils.ts
@@ -0,0 +1,69 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+// @ts-expect-error: Could not find a declaration file for module
+import { createFrame } from 'handlebars/dist/cjs/handlebars/utils';
+
+import type { AmbiguousHelperOptions, DecoratorOptions } from './types';
+
+export function isBlock(node: hbs.AST.Node): node is hbs.AST.BlockStatement {
+ return 'program' in node || 'inverse' in node;
+}
+
+export function isDecorator(
+ node: hbs.AST.Node
+): node is hbs.AST.Decorator | hbs.AST.DecoratorBlock {
+ return node.type === 'Decorator' || node.type === 'DecoratorBlock';
+}
+
+export function toDecoratorOptions(options: AmbiguousHelperOptions) {
+ // There's really no tests/documentation on this, but to match the upstream codebase we'll remove `lookupProperty` from the decorator context
+ delete (options as any).lookupProperty;
+
+ return options as DecoratorOptions;
+}
+
+export function noop() {
+ return '';
+}
+
+// liftet from handlebars lib/handlebars/runtime.js
+export function initData(context: any, data: any) {
+ if (!data || !('root' in data)) {
+ data = data ? createFrame(data) : {};
+ data.root = context;
+ }
+ return data;
+}
+
+// liftet from handlebars lib/handlebars/compiler/compiler.js
+export function transformLiteralToPath(node: { path: hbs.AST.PathExpression | hbs.AST.Literal }) {
+ const pathIsLiteral = 'parts' in node.path === false;
+
+ if (pathIsLiteral) {
+ const literal = node.path;
+ // @ts-expect-error: Not all `hbs.AST.Literal` sub-types has an `original` property, but that's ok, in that case we just want `undefined`
+ const original = literal.original;
+ // Casting to string here to make false and 0 literal values play nicely with the rest
+ // of the system.
+ node.path = {
+ type: 'PathExpression',
+ data: false,
+ depth: 0,
+ parts: [original + ''],
+ original: original + '',
+ loc: literal.loc,
+ };
+ }
+}
+
+export function allowUnsafeEval() {
+ try {
+ new Function();
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/dev/lib/handlebars/src/visitor.ts b/dev/lib/handlebars/src/visitor.ts
new file mode 100644
index 00000000..1842c8e5
--- /dev/null
+++ b/dev/lib/handlebars/src/visitor.ts
@@ -0,0 +1,778 @@
+/*
+ * Elasticsearch B.V licenses this file to you under the MIT License.
+ * See `packages/kbn-handlebars/LICENSE` for more information.
+ */
+
+import Handlebars from 'handlebars';
+import {
+ createProtoAccessControl,
+ resultIsAllowed,
+ // @ts-expect-error: Could not find a declaration file for module
+} from 'handlebars/dist/cjs/handlebars/internal/proto-access';
+// @ts-expect-error: Could not find a declaration file for module
+import AST from 'handlebars/dist/cjs/handlebars/compiler/ast';
+// @ts-expect-error: Could not find a declaration file for module
+import { indexOf, createFrame } from 'handlebars/dist/cjs/handlebars/utils';
+// @ts-expect-error: Could not find a declaration file for module
+import { moveHelperToHooks } from 'handlebars/dist/cjs/handlebars/helpers';
+
+import type {
+ AmbiguousHelperOptions,
+ CompileOptions,
+ Container,
+ DecoratorDelegate,
+ DecoratorsHash,
+ HelperOptions,
+ NodeType,
+ NonBlockHelperOptions,
+ ProcessableBlockStatementNode,
+ ProcessableNode,
+ ProcessableNodeWithPathParts,
+ ProcessableNodeWithPathPartsOrLiteral,
+ ProcessableStatementNode,
+ ResolvePartialOptions,
+ RuntimeOptions,
+ Template,
+ TemplateDelegate,
+ VisitorHelper,
+} from './types';
+import { kAmbiguous, kHelper, kSimple } from './symbols';
+import {
+ initData,
+ isBlock,
+ isDecorator,
+ noop,
+ toDecoratorOptions,
+ transformLiteralToPath,
+} from './utils';
+
+export class ElasticHandlebarsVisitor extends Handlebars.Visitor {
+ private env: typeof Handlebars;
+ private contexts: any[] = [];
+ private output: any[] = [];
+ private template?: string;
+ private compileOptions: CompileOptions;
+ private runtimeOptions?: RuntimeOptions;
+ private blockParamNames: any[][] = [];
+ private blockParamValues: any[][] = [];
+ private ast?: hbs.AST.Program;
+ private container: Container;
+ private defaultHelperOptions: Pick<NonBlockHelperOptions, 'lookupProperty'>;
+ private processedRootDecorators = false; // Root decorators should not have access to input arguments. This flag helps us detect them.
+ private processedDecoratorsForProgram = new Set(); // It's important that a given program node only has its decorators run once, we use this Map to keep track of them
+
+ constructor(
+ env: typeof Handlebars,
+ input: string | hbs.AST.Program,
+ options: CompileOptions = {}
+ ) {
+ super();
+
+ this.env = env;
+
+ if (typeof input !== 'string' && input.type === 'Program') {
+ this.ast = input;
+ } else {
+ this.template = input as string;
+ }
+
+ this.compileOptions = { data: true, ...options };
+ this.compileOptions.knownHelpers = Object.assign(
+ Object.create(null),
+ {
+ helperMissing: true,
+ blockHelperMissing: true,
+ each: true,
+ if: true,
+ unless: true,
+ with: true,
+ log: true,
+ lookup: true,
+ },
+ this.compileOptions.knownHelpers
+ );
+
+ const protoAccessControl = createProtoAccessControl({});
+
+ const container: Container = (this.container = {
+ helpers: {},
+ partials: {},
+ decorators: {},
+ strict(obj, name, loc) {
+ if (!obj || !(name in obj)) {
+ throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, {
+ loc,
+ } as hbs.AST.Node);
+ }
+ return container.lookupProperty(obj, name);
+ },
+ // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js)
+ lookupProperty(parent, propertyName) {
+ const result = parent[propertyName];
+ if (result == null) {
+ return result;
+ }
+ if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
+ return result;
+ }
+
+ if (resultIsAllowed(result, protoAccessControl, propertyName)) {
+ return result;
+ }
+ return undefined;
+ },
+ // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js)
+ lambda(current, context) {
+ return typeof current === 'function' ? current.call(context) : current;
+ },
+ data(value: any, depth: number) {
+ while (value && depth--) {
+ value = value._parent;
+ }
+ return value;
+ },
+ hooks: {},
+ });
+
+ this.defaultHelperOptions = {
+ lookupProperty: container.lookupProperty,
+ };
+ }
+
+ render(context: any, options: RuntimeOptions = {}): string {
+ this.contexts = [context];
+ this.output = [];
+ this.runtimeOptions = { ...options };
+ this.container.helpers = { ...this.env.helpers, ...options.helpers };
+ this.container.partials = { ...this.env.partials, ...options.partials };
+ this.container.decorators = {
+ ...(this.env.decorators as DecoratorsHash),
+ ...options.decorators,
+ };
+ this.container.hooks = {};
+ this.processedRootDecorators = false;
+ this.processedDecoratorsForProgram.clear();
+
+ if (this.compileOptions.data) {
+ this.runtimeOptions.data = initData(context, this.runtimeOptions.data);
+ }
+
+ const keepHelperInHelpers = false;
+ moveHelperToHooks(this.container, 'helperMissing', keepHelperInHelpers);
+ moveHelperToHooks(this.container, 'blockHelperMissing', keepHelperInHelpers);
+
+ if (!this.ast) {
+ this.ast = Handlebars.parse(this.template!);
+ }
+
+ // The `defaultMain` function contains the default behavior:
+ //
+ // Generate a "program" function based on the root `Program` in the AST and
+ // call it. This will start the processing of all the child nodes in the
+ // AST.
+ const defaultMain: TemplateDelegate = (_context) => {
+ const prog = this.generateProgramFunction(this.ast!);
+ return prog(_context, this.runtimeOptions);
+ };
+
+ // Run any decorators that might exist on the root:
+ //
+ // The `defaultMain` function is passed in, and if there are no root
+ // decorators, or if the decorators chooses to do so, the same function is
+ // returned from `processDecorators` and the default behavior is retained.
+ //
+ // Alternatively any of the root decorators might call the `defaultMain`
+ // function themselves, process its return value, and return a completely
+ // different `main` function.
+ const main = this.processDecorators(this.ast, defaultMain);
+ this.processedRootDecorators = true;
+
+ // Call the `main` function and add the result to the final output.
+ const result = main(this.context, options);
+
+ if (main === defaultMain) {
+ this.output.push(result);
+ return this.output.join('');
+ } else {
+ // We normally expect the return value of `main` to be a string. However,
+ // if a decorator is used to override the `defaultMain` function, the
+ // return value can be any type. To match the upstream handlebars project
+ // behavior, we want the result of rendering the template to be the
+ // literal value returned by the decorator.
+ //
+ // Since the output array in this case always will be empty, we just
+ // return that single value instead of attempting to join all the array
+ // elements as strings.
+ return result;
+ }
+ }
+
+ // ********************************************** //
+ // *** Visitor AST Traversal Functions *** //
+ // ********************************************** //
+
+ Program(program: hbs.AST.Program) {
+ this.blockParamNames.unshift(program.blockParams);
+ super.Program(program);
+ this.blockParamNames.shift();
+ }
+
+ MustacheStatement(mustache: hbs.AST.MustacheStatement) {
+ this.processStatementOrExpression(mustache);
+ }
+
+ BlockStatement(block: hbs.AST.BlockStatement) {
+ this.processStatementOrExpression(block);
+ }
+
+ PartialStatement(partial: hbs.AST.PartialStatement) {
+ this.invokePartial(partial);
+ }
+
+ PartialBlockStatement(partial: hbs.AST.PartialBlockStatement) {
+ this.invokePartial(partial);
+ }
+
+ // This space is intentionally left blank: We want to override the Visitor
+ // class implementation of this method, but since we handle decorators
+ // separately before traversing the nodes, we just want to make this a no-op.
+ DecoratorBlock(decorator: hbs.AST.DecoratorBlock) {}
+
+ // This space is intentionally left blank: We want to override the Visitor
+ // class implementation of this method, but since we handle decorators
+ // separately before traversing the nodes, we just want to make this a no-op.
+ Decorator(decorator: hbs.AST.Decorator) {}
+
+ SubExpression(sexpr: hbs.AST.SubExpression) {
+ this.processStatementOrExpression(sexpr);
+ }
+
+ PathExpression(path: hbs.AST.PathExpression) {
+ const blockParamId =
+ !path.depth && !AST.helpers.scopedId(path) && this.blockParamIndex(path.parts[0]);
+
+ let result;
+ if (blockParamId) {
+ result = this.lookupBlockParam(blockParamId, path);
+ } else if (path.data) {
+ result = this.lookupData(this.runtimeOptions!.data, path);
+ } else {
+ result = this.resolvePath(this.contexts[path.depth], path);
+ }
+
+ this.output.push(result);
+ }
+
+ ContentStatement(content: hbs.AST.ContentStatement) {
+ this.output.push(content.value);
+ }
+
+ StringLiteral(string: hbs.AST.StringLiteral) {
+ this.output.push(string.value);
+ }
+
+ NumberLiteral(number: hbs.AST.NumberLiteral) {
+ this.output.push(number.value);
+ }
+
+ BooleanLiteral(bool: hbs.AST.BooleanLiteral) {
+ this.output.push(bool.value);
+ }
+
+ UndefinedLiteral() {
+ this.output.push(undefined);
+ }
+
+ NullLiteral() {
+ this.output.push(null);
+ }
+
+ // ********************************************** //
+ // *** Visitor AST Helper Functions *** //
+ // ********************************************** //
+
+ /**
+ * Special code for decorators, since they have to be executed ahead of time (before the wrapping program).
+ * So we have to look into the program AST body and see if it contains any decorators that we have to process
+ * before we can finish processing of the wrapping program.
+ */
+ private processDecorators(program: hbs.AST.Program, prog: TemplateDelegate) {
+ if (!this.processedDecoratorsForProgram.has(program)) {
+ this.processedDecoratorsForProgram.add(program);
+ const props = {};
+ for (const node of program.body) {
+ if (isDecorator(node)) {
+ prog = this.processDecorator(node, prog, props);
+ }
+ }
+ }
+
+ return prog;
+ }
+
+ private processDecorator(
+ decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator,
+ prog: TemplateDelegate,
+ props: Record<string, any>
+ ) {
+ const options = this.setupDecoratorOptions(decorator);
+
+ const result = this.container.lookupProperty<DecoratorDelegate>(
+ this.container.decorators,
+ options.name
+ )(prog, props, this.container, options);
+
+ return Object.assign(result || prog, props);
+ }
+
+ private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) {
+ // Calling `transformLiteralToPath` has side-effects!
+ // It converts a node from type `ProcessableNodeWithPathPartsOrLiteral` to `ProcessableNodeWithPathParts`
+ transformLiteralToPath(node);
+
+ switch (this.classifyNode(node as ProcessableNodeWithPathParts)) {
+ case kSimple:
+ this.processSimpleNode(node as ProcessableNodeWithPathParts);
+ break;
+ case kHelper:
+ this.processHelperNode(node as ProcessableNodeWithPathParts);
+ break;
+ case kAmbiguous:
+ this.processAmbiguousNode(node as ProcessableNodeWithPathParts);
+ break;
+ }
+ }
+
+ // Liftet from lib/handlebars/compiler/compiler.js (original name: classifySexpr)
+ private classifyNode(node: { path: hbs.AST.PathExpression }): NodeType {
+ const isSimple = AST.helpers.simpleId(node.path);
+ const isBlockParam = isSimple && !!this.blockParamIndex(node.path.parts[0]);
+
+ // a mustache is an eligible helper if:
+ // * its id is simple (a single part, not `this` or `..`)
+ let isHelper = !isBlockParam && AST.helpers.helperExpression(node);
+
+ // if a mustache is an eligible helper but not a definite
+ // helper, it is ambiguous, and will be resolved in a later
+ // pass or at runtime.
+ let isEligible = !isBlockParam && (isHelper || isSimple);
+
+ // if ambiguous, we can possibly resolve the ambiguity now
+ // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc.
+ if (isEligible && !isHelper) {
+ const name = node.path.parts[0];
+ const options = this.compileOptions;
+ if (options.knownHelpers && options.knownHelpers[name]) {
+ isHelper = true;
+ } else if (options.knownHelpersOnly) {
+ isEligible = false;
+ }
+ }
+
+ if (isHelper) {
+ return kHelper;
+ } else if (isEligible) {
+ return kAmbiguous;
+ } else {
+ return kSimple;
+ }
+ }
+
+ // Liftet from lib/handlebars/compiler/compiler.js
+ private blockParamIndex(name: string): [number, any] | undefined {
+ for (let depth = 0, len = this.blockParamNames.length; depth < len; depth++) {
+ const blockParams = this.blockParamNames[depth];
+ const param = blockParams && indexOf(blockParams, name);
+ if (blockParams && param >= 0) {
+ return [depth, param];
+ }
+ }
+ }
+
+ // Looks up the value of `parts` on the given block param and pushes
+ // it onto the stack.
+ private lookupBlockParam(blockParamId: [number, any], path: hbs.AST.PathExpression) {
+ const value = this.blockParamValues[blockParamId[0]][blockParamId[1]];
+ return this.resolvePath(value, path, 1);
+ }
+
+ // Push the data lookup operator
+ private lookupData(data: any, path: hbs.AST.PathExpression) {
+ if (path.depth) {
+ data = this.container.data(data, path.depth);
+ }
+
+ return this.resolvePath(data, path);
+ }
+
+ private processSimpleNode(node: ProcessableNodeWithPathParts) {
+ const path = node.path;
+ // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars
+ path.strict = true;
+ const result = this.resolveNodes(path)[0];
+ const lambdaResult = this.container.lambda(result, this.context);
+
+ if (isBlock(node)) {
+ this.blockValue(node, lambdaResult);
+ } else {
+ this.output.push(lambdaResult);
+ }
+ }
+
+ // The purpose of this opcode is to take a block of the form
+ // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and
+ // replace it on the stack with the result of properly
+ // invoking blockHelperMissing.
+ private blockValue(node: hbs.AST.BlockStatement, value: any) {
+ const name = node.path.original;
+ const options = this.setupParams(node, name);
+
+ const result = this.container.hooks.blockHelperMissing!.call(this.context, value, options);
+
+ this.output.push(result);
+ }
+
+ private processHelperNode(node: ProcessableNodeWithPathParts) {
+ const path = node.path;
+ const name = path.parts[0];
+
+ if (this.compileOptions.knownHelpers && this.compileOptions.knownHelpers[name]) {
+ this.invokeKnownHelper(node);
+ } else if (this.compileOptions.knownHelpersOnly) {
+ throw new Handlebars.Exception(
+ 'You specified knownHelpersOnly, but used the unknown helper ' + name,
+ node
+ );
+ } else {
+ this.invokeHelper(node);
+ }
+ }
+
+ // This operation is used when the helper is known to exist,
+ // so a `helperMissing` fallback is not required.
+ private invokeKnownHelper(node: ProcessableNodeWithPathParts) {
+ const name = node.path.parts[0];
+ const helper = this.setupHelper(node, name);
+ // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards
+ const result = helper.fn!.call(helper.context, ...helper.params, helper.options);
+ this.output.push(result);
+ }
+
+ // Pops off the helper's parameters, invokes the helper,
+ // and pushes the helper's return value onto the stack.
+ //
+ // If the helper is not found, `helperMissing` is called.
+ private invokeHelper(node: ProcessableNodeWithPathParts) {
+ const path = node.path;
+ const name = path.original;
+ const isSimple = AST.helpers.simpleId(path);
+ const helper = this.setupHelper(node, name);
+
+ const loc = isSimple && helper.fn ? node.loc : path.loc;
+ helper.fn = (isSimple && helper.fn) || this.resolveNodes(path)[0];
+
+ if (!helper.fn) {
+ if (this.compileOptions.strict) {
+ helper.fn = this.container.strict(helper.context, name, loc);
+ } else {
+ helper.fn = this.container.hooks.helperMissing;
+ }
+ }
+
+ // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards
+ const result = helper.fn!.call(helper.context, ...helper.params, helper.options);
+
+ this.output.push(result);
+ }
+
+ private invokePartial(partial: hbs.AST.PartialStatement | hbs.AST.PartialBlockStatement) {
+ const { params } = partial;
+ if (params.length > 1) {
+ throw new Handlebars.Exception(
+ `Unsupported number of partial arguments: ${params.length}`,
+ partial
+ );
+ }
+
+ const isDynamic = partial.name.type === 'SubExpression';
+ const name = isDynamic
+ ? this.resolveNodes(partial.name).join('')
+ : (partial.name as hbs.AST.PathExpression).original;
+
+ const options: AmbiguousHelperOptions & ResolvePartialOptions = this.setupParams(partial, name);
+ options.helpers = this.container.helpers;
+ options.partials = this.container.partials;
+ options.decorators = this.container.decorators;
+
+ let partialBlock;
+ if ('fn' in options && options.fn !== noop) {
+ const { fn } = options;
+ const currentPartialBlock = options.data?.['partial-block'];
+ options.data = createFrame(options.data);
+
+ // Wrapper function to get access to currentPartialBlock from the closure
+ partialBlock = options.data['partial-block'] = function partialBlockWrapper(
+ context: any,
+ wrapperOptions: { data?: HelperOptions['data'] } = {}
+ ) {
+ // Restore the partial-block from the closure for the execution of the block
+ // i.e. the part inside the block of the partial call.
+ wrapperOptions.data = createFrame(wrapperOptions.data);
+ wrapperOptions.data['partial-block'] = currentPartialBlock;
+ return fn(context, wrapperOptions);
+ };
+
+ if (fn.partials) {
+ options.partials = { ...options.partials, ...fn.partials };
+ }
+ }
+
+ let context = {};
+ if (params.length === 0 && !this.compileOptions.explicitPartialContext) {
+ context = this.context;
+ } else if (params.length === 1) {
+ context = this.resolveNodes(params[0])[0];
+ }
+
+ if (Object.keys(options.hash).length > 0) {
+ // TODO: context can be an array, but maybe never when we have a hash???
+ context = Object.assign({}, context, options.hash);
+ }
+
+ const partialTemplate: Template | undefined =
+ this.container.partials[name] ??
+ partialBlock ??
+ // TypeScript note: We extend ResolvePartialOptions in our types.ts file
+ // to fix an error in the upstream type. When calling back into the
+ // upstream code, we just cast back to the non-extended type
+ Handlebars.VM.resolvePartial(
+ undefined,
+ undefined,
+ options as Handlebars.ResolvePartialOptions
+ );
+
+ if (partialTemplate === undefined) {
+ throw new Handlebars.Exception(`The partial ${name} could not be found`);
+ }
+
+ let render;
+ if (typeof partialTemplate === 'string') {
+ render = this.env.compileAST(partialTemplate, this.compileOptions);
+ if (name in this.container.partials) {
+ this.container.partials[name] = render;
+ }
+ } else {
+ render = partialTemplate;
+ }
+
+ let result = render(context, options);
+
+ if ('indent' in partial) {
+ result =
+ partial.indent +
+ (this.compileOptions.preventIndent
+ ? result
+ : result.replace(/\n(?!$)/g, `\n${partial.indent}`)); // indent each line, ignoring any trailing linebreak
+ }
+
+ this.output.push(result);
+ }
+
+ private processAmbiguousNode(node: ProcessableNodeWithPathParts) {
+ const name = node.path.parts[0];
+ const helper = this.setupHelper(node, name);
+ let { fn: helperFn } = helper;
+
+ const loc = helperFn ? node.loc : node.path.loc;
+ helperFn = helperFn ?? this.resolveNodes(node.path)[0];
+
+ if (helperFn === undefined) {
+ if (this.compileOptions.strict) {
+ helperFn = this.container.strict(helper.context, name, loc);
+ } else {
+ helperFn =
+ helper.context != null
+ ? this.container.lookupProperty(helper.context, name)
+ : helper.context;
+ if (helperFn == null) helperFn = this.container.hooks.helperMissing;
+ }
+ }
+
+ const helperResult =
+ typeof helperFn === 'function'
+ ? helperFn.call(helper.context, ...helper.params, helper.options)
+ : helperFn;
+
+ if (isBlock(node)) {
+ const result = helper.fn
+ ? helperResult
+ : this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options);
+ if (result != null) {
+ this.output.push(result);
+ }
+ } else {
+ if (
+ (node as hbs.AST.MustacheStatement).escaped === false ||
+ this.compileOptions.noEscape === true ||
+ typeof helperResult !== 'string'
+ ) {
+ this.output.push(helperResult);
+ } else {
+ this.output.push(Handlebars.escapeExpression(helperResult));
+ }
+ }
+ }
+
+ private setupHelper(node: ProcessableNode, helperName: string): VisitorHelper {
+ return {
+ fn: this.container.lookupProperty(this.container.helpers, helperName),
+ context: this.context,
+ params: this.resolveNodes(node.params),
+ options: this.setupParams(node, helperName),
+ };
+ }
+
+ private setupDecoratorOptions(decorator: hbs.AST.Decorator | hbs.AST.DecoratorBlock) {
+ // TypeScript: The types indicate that `decorator.path` technically can be an `hbs.AST.Literal`. However, the upstream codebase always treats it as an `hbs.AST.PathExpression`, so we do too.
+ const name = (decorator.path as hbs.AST.PathExpression).original;
+ const options = toDecoratorOptions(this.setupParams(decorator, name));
+
+ if (decorator.params.length > 0) {
+ if (!this.processedRootDecorators) {
+ // When processing the root decorators, temporarily remove the root context so it's not accessible to the decorator
+ const context = this.contexts.shift();
+ options.args = this.resolveNodes(decorator.params);
+ this.contexts.unshift(context);
+ } else {
+ options.args = this.resolveNodes(decorator.params);
+ }
+ } else {
+ options.args = [];
+ }
+
+ return options;
+ }
+
+ private setupParams(node: ProcessableBlockStatementNode, name: string): HelperOptions;
+ private setupParams(node: ProcessableStatementNode, name: string): NonBlockHelperOptions;
+ private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions;
+ private setupParams(node: ProcessableNode, name: string) {
+ const options: AmbiguousHelperOptions = {
+ name,
+ hash: this.getHash(node),
+ data: this.runtimeOptions!.data,
+ loc: { start: node.loc.start, end: node.loc.end },
+ ...this.defaultHelperOptions,
+ };
+
+ if (isBlock(node)) {
+ (options as HelperOptions).fn = node.program
+ ? this.processDecorators(node.program, this.generateProgramFunction(node.program))
+ : noop;
+ (options as HelperOptions).inverse = node.inverse
+ ? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse))
+ : noop;
+ }
+
+ return options;
+ }
+
+ private generateProgramFunction(program: hbs.AST.Program) {
+ if (!program) return noop;
+
+ const prog: TemplateDelegate = (nextContext: any, runtimeOptions: RuntimeOptions = {}) => {
+ runtimeOptions = { ...runtimeOptions };
+
+ // inherit data in blockParams from parent program
+ runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data;
+ if (runtimeOptions.blockParams) {
+ runtimeOptions.blockParams = runtimeOptions.blockParams.concat(
+ this.runtimeOptions!.blockParams
+ );
+ }
+
+ // inherit partials from parent program
+ runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials;
+
+ // stash parent program data
+ const tmpRuntimeOptions = this.runtimeOptions;
+ this.runtimeOptions = runtimeOptions;
+ const shiftContext = nextContext !== this.context;
+ if (shiftContext) this.contexts.unshift(nextContext);
+ this.blockParamValues.unshift(runtimeOptions.blockParams || []);
+
+ // execute child program
+ const result = this.resolveNodes(program).join('');
+
+ // unstash parent program data
+ this.blockParamValues.shift();
+ if (shiftContext) this.contexts.shift();
+ this.runtimeOptions = tmpRuntimeOptions;
+
+ // return result of child program
+ return result;
+ };
+
+ prog.blockParams = program.blockParams?.length ?? 0;
+ return prog;
+ }
+
+ private getHash(statement: { hash?: hbs.AST.Hash }) {
+ const result: { [key: string]: any } = {};
+ if (!statement.hash) return result;
+ for (const { key, value } of statement.hash.pairs) {
+ result[key] = this.resolveNodes(value)[0];
+ }
+ return result;
+ }
+
+ private resolvePath(obj: any, path: hbs.AST.PathExpression, index = 0) {
+ if (this.compileOptions.strict || this.compileOptions.assumeObjects) {
+ return this.strictLookup(obj, path);
+ }
+
+ for (; index < path.parts.length; index++) {
+ if (obj == null) return;
+ obj = this.container.lookupProperty(obj, path.parts[index]);
+ }
+
+ return obj;
+ }
+
+ private strictLookup(obj: any, path: hbs.AST.PathExpression) {
+ // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars
+ const requireTerminal = this.compileOptions.strict && path.strict;
+ const len = path.parts.length - (requireTerminal ? 1 : 0);
+
+ for (let i = 0; i < len; i++) {
+ obj = this.container.lookupProperty(obj, path.parts[i]);
+ }
+
+ if (requireTerminal) {
+ return this.container.strict(obj, path.parts[len], path.loc);
+ } else {
+ return obj;
+ }
+ }
+
+ private resolveNodes(nodes: hbs.AST.Node | hbs.AST.Node[]): any[] {
+ const currentOutput = this.output;
+ this.output = [];
+
+ if (Array.isArray(nodes)) {
+ this.acceptArray(nodes);
+ } else {
+ this.accept(nodes);
+ }
+
+ const result = this.output;
+
+ this.output = currentOutput;
+
+ return result;
+ }
+
+ private get context() {
+ return this.contexts[0];
+ }
+}
diff --git a/dev/lib/handlebars/tsconfig.json b/dev/lib/handlebars/tsconfig.json
new file mode 100644
index 00000000..3f139716
--- /dev/null
+++ b/dev/lib/handlebars/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "outDir": "target/types",
+ "types": [
+ "jest",
+ "node"
+ ]
+ },
+ "include": [
+ "**/*.ts"
+ ],
+ "exclude": [
+ "target/**/*",
+ ]
+}