[@kbn/handlebars] add support for decorators (#146181)

Closes #145322
This commit is contained in:
Thomas Watson 2022-12-05 08:45:23 +01:00 committed by GitHub
parent c3ce5f7fab
commit aa344928d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 678 additions and 177 deletions

View file

@ -1,4 +1,4 @@
1,3c1,12
1,3c1,13
< describe('blocks', function() {
< it('array', function() {
< var string = '{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!';
@ -10,12 +10,13 @@
> * See `packages/kbn-handlebars/LICENSE` for more information.
> */
>
> import Handlebars from '../..';
> import { expectTemplate } from '../__jest__/test_bench';
>
> describe('blocks', () => {
> it('array', () => {
> const string = '{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!';
7,12c16,17
7,12c17,18
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -25,15 +26,15 @@
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
> world: 'world',
14d18
14d19
< .withMessage('Arrays iterate over the contents when not empty')
20c24
20c25
< world: 'world'
---
> world: 'world',
22d25
22d26
< .withMessage('Arrays ignore the contents when empty')
26,29c29,30
26,29c30,31
< it('array without data', function() {
< expectTemplate(
< '{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}'
@ -41,7 +42,7 @@
---
> it('array without data', () => {
> expectTemplate('{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}')
31,36c32,33
31,36c33,34
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -51,9 +52,9 @@
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
> world: 'world',
38d34
38d35
< .withCompileOptions({ compat: false })
42,45c38,39
42,45c39,40
< it('array with @index', function() {
< expectTemplate(
< '{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!'
@ -61,7 +62,7 @@
---
> it('array with @index', () => {
> expectTemplate('{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!')
47,52c41,42
47,52c42,43
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -71,15 +72,15 @@
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
> world: 'world',
54d43
54d44
< .withMessage('The @index variable is used')
58,59c47,48
58,59c48,49
< it('empty block', function() {
< var string = '{{#goodbyes}}{{/goodbyes}}cruel {{world}}!';
---
> it('empty block', () => {
> const string = '{{#goodbyes}}{{/goodbyes}}cruel {{world}}!';
63,68c52,53
63,68c53,54
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -89,19 +90,19 @@
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
> world: 'world',
70d54
70d55
< .withMessage('Arrays iterate over the contents when not empty')
76c60
76c61
< world: 'world'
---
> world: 'world',
78d61
78d62
< .withMessage('Arrays ignore the contents when empty')
82c65
82c66
< it('block with complex lookup', function() {
---
> it('block with complex lookup', () => {
86,90c69
86,90c70
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -109,7 +110,7 @@
< ]
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
92,97c71
92,97c72
< .withMessage(
< 'Templates can access variables in contexts up the stack with relative path syntax'
< )
@ -118,11 +119,11 @@
< );
---
> .toCompileTo('goodbye cruel Alan! Goodbye cruel Alan! GOODBYE cruel Alan! ');
100c74
100c75
< it('multiple blocks with complex lookup', function() {
---
> it('multiple blocks with complex lookup', () => {
104,108c78
104,108c79
< goodbyes: [
< { text: 'goodbye' },
< { text: 'Goodbye' },
@ -130,7 +131,7 @@
< ]
---
> goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }],
113,116c83,84
113,116c84,85
< it('block with complex lookup using nested context', function() {
< expectTemplate(
< '{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}'
@ -138,15 +139,15 @@
---
> it('block with complex lookup using nested context', () => {
> expectTemplate('{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}').toThrow(Error);
119c87
119c88
< it('block with deep nested complex lookup', function() {
---
> it('block with deep nested complex lookup', () => {
125c93
125c94
< outer: [{ sibling: 'sad', inner: [{ text: 'goodbye' }] }]
---
> outer: [{ sibling: 'sad', inner: [{ text: 'goodbye' }] }],
130,133c98,99
130,133c99,100
< it('works with cached blocks', function() {
< expectTemplate(
< '{{#each person}}{{#with .}}{{first}} {{last}}{{/with}}{{/each}}'
@ -154,25 +155,25 @@
---
> it('works with cached blocks', () => {
> expectTemplate('{{#each person}}{{#with .}}{{first}} {{last}}{{/with}}{{/each}}')
138,139c104,105
138,139c105,106
< { first: 'Alan', last: 'Johnson' }
< ]
---
> { first: 'Alan', last: 'Johnson' },
> ],
144,145c110,111
144,145c111,112
< describe('inverted sections', function() {
< it('inverted sections with unset value', function() {
---
> describe('inverted sections', () => {
> it('inverted sections with unset value', () => {
148,150c114
148,150c115
< )
< .withMessage("Inverted section rendered when value isn't set.")
< .toCompileTo('Right On!');
---
> ).toCompileTo('Right On!');
153,156c117,118
153,156c118,119
< it('inverted section with false value', function() {
< expectTemplate(
< '{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}'
@ -180,9 +181,9 @@
---
> it('inverted section with false value', () => {
> expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}')
158d119
158d120
< .withMessage('Inverted section rendered when value is false.')
162,165c123,124
162,165c124,125
< it('inverted section with empty set', function() {
< expectTemplate(
< '{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}'
@ -190,23 +191,23 @@
---
> it('inverted section with empty set', () => {
> expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}')
167d125
167d126
< .withMessage('Inverted section rendered when value is empty set.')
171c129
171c130
< it('block inverted sections', function() {
---
> it('block inverted sections', () => {
177c135
177c136
< it('chained inverted sections', function() {
---
> it('chained inverted sections', () => {
188,190c146
188,190c147
< expectTemplate(
< '{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}'
< )
---
> expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}')
195,198c151,152
195,198c152,153
< it('chained inverted sections with mismatch', function() {
< expectTemplate(
< '{{#people}}{{name}}{{else if none}}{{none}}{{/if}}'
@ -214,21 +215,21 @@
---
> it('chained inverted sections with mismatch', () => {
> expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/if}}').toThrow(Error);
201c155
201c156
< it('block inverted sections with empty arrays', function() {
---
> it('block inverted sections with empty arrays', () => {
205c159
205c160
< people: []
---
> people: [],
211,212c165,166
211,212c166,167
< describe('standalone sections', function() {
< it('block standalone else sections', function() {
---
> describe('standalone sections', () => {
> it('block standalone else sections', () => {
226,241c180,181
226,241c181,182
< it('block standalone else sections can be disabled', function() {
< expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n')
< .withInput({ none: 'No people' })
@ -248,22 +249,21 @@
---
> it('block standalone chained else sections', () => {
> expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{/people}}\n')
245,247c185
245,247c186
< expectTemplate(
< '{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n'
< )
---
> expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n')
252c190
252c191
< it('should handle nesting', function() {
---
> it('should handle nesting', () => {
255c193
255c194
< data: [1, 3, 5]
---
> data: [1, 3, 5],
260,455d197
<
261,297c200,201
< describe('compat mode', function() {
< it('block with deep recursive lookup lookup', function() {
< expectTemplate(
@ -301,143 +301,181 @@
<
< describe('decorators', function() {
< it('should apply mustache decorators', function() {
< expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
---
> describe('decorators', () => {
> it('should apply mustache decorators', () => {
299c203
< .withHelper('helper', function(options) {
< return options.fn.run;
< })
---
> .withHelper('helper', function (options) {
302c206,207
< .withDecorator('decorator', function(fn) {
< fn.run = 'success';
< return fn;
< })
< .toCompileTo('success');
< });
<
---
> .withDecorator('decorator', function (fn) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
309c214
< it('should apply allow undefined return', function() {
< expectTemplate('{{#helper}}{{*decorator}}suc{{/helper}}')
---
> it('should apply allow undefined return', () => {
311c216
< .withHelper('helper', function(options) {
< return options.fn() + options.fn.run;
< })
---
> .withHelper('helper', function (options) {
314c219,220
< .withDecorator('decorator', function(fn) {
< fn.run = 'cess';
< })
< .toCompileTo('success');
< });
<
---
> .withDecorator('decorator', function (fn) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
320,324c226,228
< it('should apply block decorators', function() {
< expectTemplate(
< '{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}'
< )
< .withHelper('helper', function(options) {
< return options.fn.run;
< })
---
> it('should apply block decorators', () => {
> expectTemplate('{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}')
> .withHelper('helper', function (options) {
327c231,232
< .withDecorator('decorator', function(fn, props, container, options) {
< fn.run = options.fn();
< return fn;
< })
< .toCompileTo('success');
< });
<
---
> .withDecorator('decorator', function (fn, props, container, options) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
334c239
< it('should support nested decorators', function() {
< expectTemplate(
< '{{#helper}}{{#*decorator}}{{#*nested}}suc{{/nested}}cess{{/decorator}}{{/helper}}'
< )
---
> it('should support nested decorators', () => {
338c243
< .withHelper('helper', function(options) {
< return options.fn.run;
< })
< .withDecorators({
---
> .withHelper('helper', function (options) {
342c247,248
< decorator: function(fn, props, container, options) {
< fn.run = options.fn.nested + options.fn();
< return fn;
< },
---
> decorator(fn, props, container, options) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
346c252
< nested: function(fn, props, container, options) {
< props.nested = options.fn();
---
> nested(fn, props, container, options) {
348c254
< }
< })
< .toCompileTo('success');
< });
<
---
> },
353c259
< it('should apply multiple decorators', function() {
< expectTemplate(
< '{{#helper}}{{#*decorator}}suc{{/decorator}}{{#*decorator}}cess{{/decorator}}{{/helper}}'
< )
---
> it('should apply multiple decorators', () => {
357c263
< .withHelper('helper', function(options) {
< return options.fn.run;
< })
---
> .withHelper('helper', function (options) {
360c266,267
< .withDecorator('decorator', function(fn, props, container, options) {
< fn.run = (fn.run || '') + options.fn();
< return fn;
< })
< .toCompileTo('success');
< });
<
---
> .withDecorator('decorator', function (fn, props, container, options) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
367c274
< it('should access parent variables', function() {
< expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}')
---
> it('should access parent variables', () => {
369c276
< .withHelper('helper', function(options) {
< return options.fn.run;
< })
---
> .withHelper('helper', function (options) {
372c279,280
< .withDecorator('decorator', function(fn, props, container, options) {
< fn.run = options.args;
< return fn;
< })
< .withInput({ foo: 'success' })
< .toCompileTo('success');
< });
<
---
> .withDecorator('decorator', function (fn, props, container, options) {
> // @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
380,381c288,289
< it('should work with root program', function() {
< var run;
< expectTemplate('{{*decorator "success"}}')
---
> it('should work with root program', () => {
> let run;
383,384c291,292
< .withDecorator('decorator', function(fn, props, container, options) {
< equals(options.args[0], 'success');
< run = true;
< return fn;
< })
< .withInput({ foo: 'success' })
< .toCompileTo('');
---
> .withDecorator('decorator', function (fn, props, container, options) {
> expect(options.args[0]).toEqual('success');
390c298
< equals(run, true);
< });
<
---
> expect(run).toEqual(true);
393,394c301,302
< it('should fail when accessing variables from root', function() {
< var run;
< expectTemplate('{{*decorator foo}}')
---
> it('should fail when accessing variables from root', () => {
> let run;
396,397c304,305
< .withDecorator('decorator', function(fn, props, container, options) {
< equals(options.args[0], undefined);
< run = true;
< return fn;
< })
< .withInput({ foo: 'fail' })
< .toCompileTo('');
---
> .withDecorator('decorator', function (fn, props, container, options) {
> expect(options.args[0]).toBeUndefined();
403c311
< equals(run, true);
< });
<
---
> expect(run).toEqual(true);
406,408c314,321
< describe('registration', function() {
< it('unregisters', function() {
< handlebarsEnv.decorators = {};
<
---
> describe('registration', () => {
> beforeEach(() => {
> global.kbnHandlebarsEnv = Handlebars.create();
> });
>
> it('unregisters', () => {
> // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property.
> kbnHandlebarsEnv!.decorators = {};
410c323
< handlebarsEnv.registerDecorator('foo', function() {
< return 'fail';
< });
<
---
> kbnHandlebarsEnv!.registerDecorator('foo', function () {
414,416c327,329
< equals(!!handlebarsEnv.decorators.foo, true);
< handlebarsEnv.unregisterDecorator('foo');
< equals(handlebarsEnv.decorators.foo, undefined);
< });
<
---
> expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true);
> kbnHandlebarsEnv!.unregisterDecorator('foo');
> expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined();
419,424c332,339
< it('allows multiple globals', function() {
< handlebarsEnv.decorators = {};
<
< handlebarsEnv.registerDecorator({
< foo: function() {},
< bar: function() {}
< });
<
---
> 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() {},
427,432c342,347
< equals(!!handlebarsEnv.decorators.foo, true);
< equals(!!handlebarsEnv.decorators.bar, true);
< handlebarsEnv.unregisterDecorator('foo');
< handlebarsEnv.unregisterDecorator('bar');
< equals(handlebarsEnv.decorators.foo, undefined);
< equals(handlebarsEnv.decorators.bar, undefined);
< });
<
---
> 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();
435,445c350,356
< it('fails with multiple and args', function() {
< shouldThrow(
< function() {
@ -449,13 +487,26 @@
< testHelper: function() {
< return 'found it!';
< }
< },
---
> 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!';
447,452c358,364
< {}
< );
< },
< Error,
< 'Arg not supported with multiple decorators'
< );
< });
< });
< });
---
> testHelper() {
> return 'found it!';
> },
> },
> {}
> );
> }).toThrow('Arg not supported with multiple decorators');

View file

@ -103,3 +103,7 @@ HandlebarsEnvironment {
"template": [Function],
}
`;
exports[`blocks decorators registration should not be able to call decorators unregistered using the \`unregisterDecorator\` function 1`] = `"lookupProperty(...) is not a function"`;
exports[`blocks decorators registration should not be able to call decorators unregistered using the \`unregisterDecorator\` function 2`] = `"decoratorFn is not a function"`;

View file

@ -92,3 +92,145 @@ it('Only provide options.fn/inverse to block helpers', () => {
expect(toNotHaveProperties.calls).toEqual(nonBlockTemplates.length * 2 * factor);
expect(toHaveProperties.calls).toEqual(blockTemplates.length * 2 * factor);
});
// 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);
});
it('should call decorator again if render function is called again', () => {
const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2;
global.kbnHandlebarsEnv = Handlebars.create();
kbnHandlebarsEnv!.registerDecorator('decorator', () => {
calls++;
});
let renderAST;
let renderEval;
if (process.env.AST || !process.env.EVAL) {
renderAST = kbnHandlebarsEnv!.compileAST('{{*decorator}}');
}
if (process.env.EVAL || !process.env.AST) {
renderEval = kbnHandlebarsEnv!.compile('{{*decorator}}');
}
let calls = 0;
if (renderAST) expect(renderAST({})).toEqual('');
if (renderEval) expect(renderEval({})).toEqual('');
expect(calls).toEqual(callsExpected);
calls = 0;
if (renderAST) expect(renderAST({})).toEqual('');
if (renderEval) expect(renderEval({})).toEqual('');
expect(calls).toEqual(callsExpected);
});
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', () => {
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('registration', () => {
beforeEach(() => {
global.kbnHandlebarsEnv = Handlebars.create();
});
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}}').toThrowErrorMatchingSnapshot();
expect(calls).toEqual(0);
});
});
});
});

View file

@ -59,7 +59,31 @@ export type ExtendedCompileOptions = Pick<
* This is a subset of all the runtime options supported by the upstream
* Handlebars module.
*/
export type ExtendedRuntimeOptions = Pick<RuntimeOptions, 'helpers' | 'blockParams' | 'data'>;
export type ExtendedRuntimeOptions = Pick<
RuntimeOptions,
'helpers' | 'blockParams' | 'data' | 'decorators'
>;
/**
* According to the [decorator docs]{@link 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.
*/
export type DecoratorFunction = (
prog: Handlebars.TemplateDelegate,
props: Record<string, any>,
container: Container,
options: any
) => any;
export interface DecoratorsHash {
[name: string]: DecoratorFunction;
}
export interface HelpersHash {
[name: string]: Handlebars.HelperDelegate;
}
/**
* Normally this namespace isn't used directly. It's required to be present by
@ -116,16 +140,18 @@ Handlebars.compileAST = function (
// If `Handlebars.compileAST` is reassigned, `this` will be undefined.
const helpers = (this ?? Handlebars).helpers;
const decorators = (this ?? Handlebars).decorators as DecoratorsHash;
const visitor = new ElasticHandlebarsVisitor(input, options, helpers);
const visitor = new ElasticHandlebarsVisitor(input, options, helpers, decorators);
return (context: any, runtimeOptions?: ExtendedRuntimeOptions) =>
visitor.render(context, runtimeOptions);
};
interface Container {
helpers: { [name: string]: Handlebars.HelperDelegate };
helpers: HelpersHash;
decorators: DecoratorsHash;
strict: (obj: { [name: string]: any }, name: string, loc: hbs.AST.SourceLocation) => any;
lookupProperty: (parent: { [name: string]: any }, propertyName: string) => any;
lookupProperty: <T = any>(parent: { [name: string]: any }, propertyName: string) => T;
lambda: (current: any, context: any) => any;
data: (value: any, depth: number) => any;
hooks: {
@ -140,18 +166,22 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
private template?: string;
private compileOptions: ExtendedCompileOptions;
private runtimeOptions?: ExtendedRuntimeOptions;
private initialHelpers: { [name: string]: Handlebars.HelperDelegate };
private initialHelpers: HelpersHash;
private initialDecorators: DecoratorsHash;
private blockParamNames: any[][] = [];
private blockParamValues: any[][] = [];
private ast?: hbs.AST.Program;
private container: Container;
// @ts-expect-error
private defaultHelperOptions: Handlebars.HelperOptions = {};
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(
input: string | hbs.AST.Program,
options: ExtendedCompileOptions = {},
helpers: { [name: string]: Handlebars.HelperDelegate }
helpers: HelpersHash,
decorators: DecoratorsHash
) {
super();
@ -184,11 +214,13 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
);
this.initialHelpers = Object.assign({}, helpers);
this.initialDecorators = Object.assign({}, decorators);
const protoAccessControl = createProtoAccessControl({});
const container: Container = (this.container = {
helpers: {},
decorators: {},
strict(obj, name, loc) {
if (!obj || !(name in obj)) {
throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, {
@ -234,7 +266,13 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
this.output = [];
this.runtimeOptions = options;
this.container.helpers = Object.assign(this.initialHelpers, options.helpers);
this.container.decorators = Object.assign(
this.initialDecorators,
options.decorators as DecoratorsHash
);
this.container.hooks = {};
this.processedRootDecorators = false;
this.processedDecoratorsForProgram.clear();
if (this.compileOptions.data) {
this.runtimeOptions.data = initData(context, this.runtimeOptions.data);
@ -259,6 +297,10 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
Program(program: hbs.AST.Program) {
this.blockParamNames.unshift(program.blockParams);
this.processDecorators(program, this.generateProgramFunction(program));
this.processedRootDecorators = true;
super.Program(program);
this.blockParamNames.shift();
}
@ -271,6 +313,16 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
this.processStatementOrExpression(block);
}
// This space intentionally left blank: We want to override the Visitor class implementation
// of `DecoratorBlock`, 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 intentionally left blank: We want to override the Visitor class implementation
// of `DecoratorBlock`, 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);
}
@ -319,6 +371,41 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
// *** 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: Handlebars.TemplateDelegate) {
if (!this.processedDecoratorsForProgram.has(program)) {
for (const node of program.body) {
if (isDecorator(node)) {
this.processDecorator(node, prog);
}
}
this.processedDecoratorsForProgram.add(program);
}
}
private processDecorator(
decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator,
prog: Handlebars.TemplateDelegate
) {
// 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 decoratorFn = this.container.lookupProperty<DecoratorFunction>(
this.container.decorators,
name
);
const props = {};
// TypeScript: Because `decorator` can be of type `hbs.AST.Decorator`, TS indicates 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 options = this.setupParams(decorator as hbs.AST.DecoratorBlock, name);
// @ts-expect-error: Property 'lookupProperty' does not exist on type 'HelperOptions'
delete options.lookupProperty; // There's really no tests/documentation on this, but to match the upstream codebase we'll remove `lookupProperty` from the decorator context
Object.assign(decoratorFn(prog, props, this.container, options) || prog, props);
}
private processStatementOrExpression(node: ProcessableNode) {
transformLiteralToPath(node);
@ -559,49 +646,72 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
name: helperName,
hash: this.getHash(node),
data: this.runtimeOptions!.data,
loc: { start: node.loc.start, end: node.loc.end },
};
if (node.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.scopes.shift();
// @ts-expect-error: Property 'args' does not exist on type 'HelperOptions'. The 'args' property is expected in decorators
options.args = this.resolveNodes(node.params);
this.scopes.unshift(context);
} else {
// @ts-expect-error: Property 'args' does not exist on type 'HelperOptions'. The 'args' property is expected in decorators
options.args = this.resolveNodes(node.params);
}
}
if (isBlock(node)) {
const generateProgramFunction = (program: hbs.AST.Program) => {
const prog = (nextContext: any, runtimeOptions: ExtendedRuntimeOptions = {}) => {
// inherit data an blockParams from parent program
runtimeOptions = Object.assign({}, runtimeOptions);
runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data;
if (runtimeOptions.blockParams) {
runtimeOptions.blockParams = runtimeOptions.blockParams.concat(
this.runtimeOptions!.blockParams
);
}
// stash parent program data
const tmpRuntimeOptions = this.runtimeOptions;
this.runtimeOptions = runtimeOptions;
const shiftContext = nextContext !== this.scopes[0];
if (shiftContext) this.scopes.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.scopes.shift();
this.runtimeOptions = tmpRuntimeOptions;
// return result of child program
return result;
};
prog.blockParams = node.program?.blockParams?.length ?? 0;
return prog;
};
options.fn = node.program ? generateProgramFunction(node.program) : noop;
options.inverse = node.inverse ? generateProgramFunction(node.inverse) : noop;
options.fn = this.generateProgramFunction(node.program);
if (node.program) this.processDecorators(node.program, options.fn);
options.inverse = this.generateProgramFunction(node.inverse);
if (node.inverse) this.processDecorators(node.inverse, options.inverse);
}
return Object.assign(options, this.defaultHelperOptions);
}
private generateProgramFunction(program?: hbs.AST.Program) {
if (!program) return noop;
const prog: Handlebars.TemplateDelegate = (
nextContext: any,
runtimeOptions: ExtendedRuntimeOptions = {}
) => {
// inherit data in blockParams from parent program
runtimeOptions = Object.assign({}, runtimeOptions);
runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data;
if (runtimeOptions.blockParams) {
runtimeOptions.blockParams = runtimeOptions.blockParams.concat(
this.runtimeOptions!.blockParams
);
}
// stash parent program data
const tmpRuntimeOptions = this.runtimeOptions;
this.runtimeOptions = runtimeOptions;
const shiftContext = nextContext !== this.scopes[0];
if (shiftContext) this.scopes.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.scopes.shift();
this.runtimeOptions = tmpRuntimeOptions;
// return result of child program
return result;
};
// @ts-expect-error: Property 'blockParams' does not exist on type 'TemplateDelegate<any>' - The types are too strict
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;
@ -666,6 +776,10 @@ function isBlock(node: hbs.AST.Node): node is hbs.AST.BlockStatement {
return 'program' in node || 'inverse' in node;
}
function isDecorator(node: hbs.AST.Node): node is hbs.AST.Decorator | hbs.AST.DecoratorBlock {
return node.type === 'Decorator' || node.type === 'DecoratorBlock';
}
function noop() {
return '';
}

View file

@ -3,7 +3,12 @@
* See `packages/kbn-handlebars/LICENSE` for more information.
*/
import Handlebars, { ExtendedCompileOptions, ExtendedRuntimeOptions } from '../..';
import Handlebars, {
type DecoratorFunction,
type DecoratorsHash,
type ExtendedCompileOptions,
type ExtendedRuntimeOptions,
} from '../..';
declare global {
var kbnHandlebarsEnv: typeof Handlebars | null; // eslint-disable-line no-var
@ -25,6 +30,7 @@ class HandlebarsTestBench {
private compileOptions?: ExtendedCompileOptions;
private runtimeOptions?: ExtendedRuntimeOptions;
private helpers: { [key: string]: Handlebars.HelperDelegate | undefined } = {};
private decorators: DecoratorsHash = {};
private input: any = {};
constructor(template: string, options: TestOptions = {}) {
@ -59,6 +65,18 @@ class HandlebarsTestBench {
return this;
}
withDecorator(name: string, decoratorFunction: DecoratorFunction) {
this.decorators[name] = decoratorFunction;
return this;
}
withDecorators(decoratorFunctions: { [key: string]: DecoratorFunction }) {
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) {
@ -119,6 +137,7 @@ class HandlebarsTestBench {
const runtimeOptions: ExtendedRuntimeOptions = Object.assign(
{
helpers: this.helpers,
decorators: this.decorators,
},
this.runtimeOptions
);
@ -132,6 +151,7 @@ class HandlebarsTestBench {
const runtimeOptions: ExtendedRuntimeOptions = Object.assign(
{
helpers: this.helpers,
decorators: this.decorators,
},
this.runtimeOptions
);

View file

@ -5,6 +5,7 @@
* See `packages/kbn-handlebars/LICENSE` for more information.
*/
import Handlebars from '../..';
import { expectTemplate } from '../__jest__/test_bench';
describe('blocks', () => {
@ -195,4 +196,173 @@ describe('blocks', () => {
.toCompileTo('1\n3\n5\nOK.');
});
});
describe('decorators', () => {
it('should apply mustache decorators', () => {
expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
.withHelper('helper', function (options) {
return options.fn.run;
})
.withDecorator('decorator', function (fn) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.run = 'success';
return fn;
})
.toCompileTo('success');
});
it('should apply allow undefined return', () => {
expectTemplate('{{#helper}}{{*decorator}}suc{{/helper}}')
.withHelper('helper', function (options) {
return options.fn() + options.fn.run;
})
.withDecorator('decorator', function (fn) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.run = 'cess';
})
.toCompileTo('success');
});
it('should apply block decorators', () => {
expectTemplate('{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}')
.withHelper('helper', function (options) {
return options.fn.run;
})
.withDecorator('decorator', function (fn, props, container, options) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.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) {
return options.fn.run;
})
.withDecorators({
decorator(fn, props, container, options) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.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) {
return options.fn.run;
})
.withDecorator('decorator', function (fn, props, container, options) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.run = (fn.run || '') + options.fn();
return fn;
})
.toCompileTo('success');
});
it('should access parent variables', () => {
expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}')
.withHelper('helper', function (options) {
return options.fn.run;
})
.withDecorator('decorator', function (fn, props, container, options) {
// @ts-expect-error: Property 'run' does not exist on type 'TemplateDelegate<any>'
fn.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();
});
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');
});
});
});
});