kibana/packages/kbn-handlebars
Kibana Machine 5b2ce19c9c
[8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960)
# Backport

This will backport the following commits from `main` to `8.6`:
- [[@kbn/handlebars] add support for decorators
(#146181)](https://github.com/elastic/kibana/pull/146181)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Thomas
Watson","email":"watson@elastic.co"},"sourceCommit":{"committedDate":"2022-12-05T07:45:23Z","message":"[@kbn/handlebars]
add support for decorators (#146181)\n\nCloses
#145322","sha":"aa344928d81250235733987a63281c2629806ba0","branchLabelMapping":{"^v8.7.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","backport:prev-minor","v8.7.0"],"number":146181,"url":"https://github.com/elastic/kibana/pull/146181","mergeCommit":{"message":"[@kbn/handlebars]
add support for decorators (#146181)\n\nCloses
#145322","sha":"aa344928d81250235733987a63281c2629806ba0"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.7.0","labelRegex":"^v8.7.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/146181","number":146181,"mergeCommit":{"message":"[@kbn/handlebars]
add support for decorators (#146181)\n\nCloses
#145322","sha":"aa344928d81250235733987a63281c2629806ba0"}}]}]
BACKPORT-->

Co-authored-by: Thomas Watson <watson@elastic.co>
2022-12-05 01:52:55 -07:00
..
.patches [8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960) 2022-12-05 01:52:55 -07:00
__snapshots__ [8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960) 2022-12-05 01:52:55 -07:00
scripts Add csp.disableUnsafeEval config option to remove the unsafe-eval CSP (#124484) 2022-05-23 11:01:56 -07:00
src [8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960) 2022-12-05 01:52:55 -07:00
.gitignore Add csp.disableUnsafeEval config option to remove the unsafe-eval CSP (#124484) 2022-05-23 11:01:56 -07:00
BUILD.bazel [auto] migrate existing plugin/package configs 2022-10-28 14:06:46 -05:00
index.test.ts [8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960) 2022-12-05 01:52:55 -07:00
index.ts [8.6] [@kbn/handlebars] add support for decorators (#146181) (#146960) 2022-12-05 01:52:55 -07:00
jest.config.js Add csp.disableUnsafeEval config option to remove the unsafe-eval CSP (#124484) 2022-05-23 11:01:56 -07:00
kibana.jsonc add kibana.jsonc files to existing packages (#138965) 2022-09-08 13:31:57 -07:00
LICENSE Move contribution declarations to the bottom of LICENSE files (#133502) 2022-06-03 17:11:53 +02:00
package.json Add csp.disableUnsafeEval config option to remove the unsafe-eval CSP (#124484) 2022-05-23 11:01:56 -07:00
README.md [8.6] Fix typos in @kbn/handlebars package (#146385) (#146392) 2022-11-28 08:08:29 -07:00
tsconfig.json fix(NA): wrongly spread stripInternal and rootDir configs across packages (#144463) 2022-11-03 01:04:55 +00:00

@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:

    • knownHelpers
    • knownHelpersOnly
    • strict
    • assumeObjects
    • noEscape
    • data
  • Only the following runtime options are supported:

    • helpers
    • blockParams
    • data

The Inline partials handlebars template feature is currently not supported by @kbn/handlebars.

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' }.
  2. The first time the "render" function is called the following happens:
    1. Turn the template string into an Abstract Syntax Tree (AST).
    2. Convert the AST into a hyper optimized JavaScript function which takes the input object as an argument.
    3. Call the generate JavaScript function with the given "input" object to produce and return the final output string (Hello World).
  3. 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' }.
  2. The first time the "render" function is called the following happens:
    1. Turn the template string into an Abstract Syntax Tree (AST).
    2. Process the AST with the given "input" object to produce and return the final output string (Hello World).
  3. 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 and walking it by implementing the Visitor API. 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:

  • scopes: 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 scopes 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:

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/upstream 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:

./packages/kbn-handlebars/scripts/check_for_test_changes.sh

If the script outputs a diff for a given file, it means that this file has been updated.

Note: that 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:

./packages/kbn-handlebars/scripts/update_test_patches.sh

This will update the .patch files inside the packages/kbn-handlebars/.patches directory. Make sure to commit those changes.

Note: If we manually make changes to our test files in the upstream directory, we need to run the update_test_patches.sh script as well.

Debugging

Print AST

To output the generated AST object structure in a somewhat readable form, use the following script:

./packages/kbn-handlebars/scripts/print_ast.js

Example:

./packages/kbn-handlebars/scripts/print_ast.js '{{value}}'

Output:

{
  type: 'Program',
  body: [
    {
      type: 'MustacheStatement',
      path: {
        type: 'PathExpression',
        data: false,
        depth: 0,
        parts: [ 'value' ],
        original: 'value'
      },
      params: [],
      hash: undefined,
      escaped: true,
      strip: { open: false, close: false }
    }
  ],
  strip: {}
}

You can also filter which properties not to display, e.g:

./packages/kbn-handlebars/scripts/print_ast.js '{{#myBlock}}Hello {{name}}{{/myBlock}}' params,hash,loc,strip,data,depth,parts,inverse,openStrip,inverseStrip,closeStrip,blockParams,escaped

Output:

{
  type: 'Program',
  body: [
    {
      type: 'BlockStatement',
      path: { type: 'PathExpression', original: 'myBlock' },
      program: {
        type: 'Program',
        body: [
          {
            type: 'ContentStatement',
            original: 'Hello ',
            value: 'Hello '
          },
          {
            type: 'MustacheStatement',
            path: { type: 'PathExpression', original: 'name' }
          }
        ]
      }
    }
  ]
}

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:

./node_modules/handlebars/print-script <template> [options]

Options:

  • -v: Enable verbose mode.

Example:

./node_modules/handlebars/print-script '{{value}}' -v

You can pretty print just the generated code using this command:

./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