[@kbn/handlebars] Support custom decorator return value (#149392)

Fixes #149327
This commit is contained in:
Thomas Watson 2023-02-01 11:57:22 +01:00 committed by GitHub
parent a373ac7336
commit f296abb6c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 297 additions and 83 deletions

View file

@ -307,7 +307,37 @@ describe('blocks', () => {
.toCompileTo('');
});
it('should pass expected options to root decorator', () => {
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(`
@ -339,6 +369,163 @@ describe('blocks', () => {
.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): Handlebars.TemplateDelegate =>
(context, options) => {
expect(context).toMatchInlineSnapshot(`
Object {
"me": "my",
}
`);
expect(options).toMatchInlineSnapshot(`
Object {
"decorators": Object {
"decorator": [Function],
},
"helpers": 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): Handlebars.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: Handlebars.HelperOptions) {
return options.fn('my', { foo: 'bar' } as any);
})
.withDecorator(
'decorator',
(fn): Handlebars.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: Handlebars.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();

View file

@ -64,6 +64,13 @@ type ProcessableNodeWithPathPartsOrLiteral = ProcessableNode & {
path: hbs.AST.PathExpression | hbs.AST.Literal;
};
interface Helper {
fn?: Handlebars.HelperDelegate;
context: any[];
params: any[];
options: AmbiguousHelperOptions;
}
export type NonBlockHelperOptions = Omit<Handlebars.HelperOptions, 'fn' | 'inverse'>;
export type AmbiguousHelperOptions = Handlebars.HelperOptions | NonBlockHelperOptions;
@ -290,7 +297,7 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
render(context: any, options: ExtendedRuntimeOptions = {}): string {
this.contexts = [context];
this.output = [];
this.runtimeOptions = options;
this.runtimeOptions = Object.assign({}, options);
this.container.helpers = Object.assign(this.initialHelpers, options.helpers);
this.container.decorators = Object.assign(
this.initialDecorators,
@ -312,9 +319,46 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
this.ast = Handlebars.parse(this.template!);
}
this.accept(this.ast);
// 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: Handlebars.TemplateDelegate = (_context) => {
const prog = this.generateProgramFunction(this.ast!);
return prog(_context, this.runtimeOptions);
};
return this.output.join('');
// 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;
}
}
// ********************************************** //
@ -323,11 +367,6 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
Program(program: hbs.AST.Program) {
this.blockParamNames.unshift(program.blockParams);
// Run any decorators that might exist on the root
this.processDecorators(program, this.generateProgramFunction(program));
this.processedRootDecorators = true;
super.Program(program);
this.blockParamNames.shift();
}
@ -340,14 +379,14 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
this.processStatementOrExpression(block);
}
// This space 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.
// 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 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.
// 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) {
@ -405,20 +444,23 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
*/
private processDecorators(program: hbs.AST.Program, prog: Handlebars.TemplateDelegate) {
if (!this.processedDecoratorsForProgram.has(program)) {
this.processedDecoratorsForProgram.add(program);
const props = {};
for (const node of program.body) {
if (isDecorator(node)) {
this.processDecorator(node, prog);
prog = this.processDecorator(node, prog, props);
}
}
this.processedDecoratorsForProgram.add(program);
}
return prog;
}
private processDecorator(
decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator,
prog: Handlebars.TemplateDelegate
prog: Handlebars.TemplateDelegate,
props: Record<string, any>
) {
const props = {};
const options = this.setupDecoratorOptions(decorator);
const result = this.container.lookupProperty<DecoratorFunction>(
@ -426,7 +468,7 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
options.name
)(prog, props, this.container, options);
Object.assign(result || prog, props);
return Object.assign(result || prog, props);
}
private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) {
@ -590,10 +632,34 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
}
private processAmbiguousNode(node: ProcessableNodeWithPathParts) {
const invokeResult = this.invokeAmbiguous(node);
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 = this.ambiguousBlockValue(node, invokeResult);
const result = helper.fn
? helperResult
: this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options);
if (result != null) {
this.output.push(result);
}
@ -601,66 +667,16 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
if (
(node as hbs.AST.MustacheStatement).escaped === false ||
this.compileOptions.noEscape === true ||
typeof invokeResult !== 'string'
typeof helperResult !== 'string'
) {
this.output.push(invokeResult);
this.output.push(helperResult);
} else {
this.output.push(Handlebars.escapeExpression(invokeResult));
this.output.push(Handlebars.escapeExpression(helperResult));
}
}
}
// This operation is used when an expression like `{{foo}}`
// is provided, but we don't know at compile-time whether it
// is a helper or a path.
//
// This operation emits more code than the other options,
// and can be avoided by passing the `knownHelpers` and
// `knownHelpersOnly` flags at compile-time.
private invokeAmbiguous(node: ProcessableNodeWithPathParts) {
const name = node.path.parts[0];
const helper = this.setupHelper(node, name);
const loc = helper.fn ? node.loc : node.path.loc;
helper.fn = helper.fn ?? this.resolveNodes(node.path)[0];
if (helper.fn === undefined) {
if (this.compileOptions.strict) {
helper.fn = this.container.strict(helper.context, name, loc);
} else {
helper.fn =
helper.context != null
? this.container.lookupProperty(helper.context, name)
: helper.context;
if (helper.fn == null) helper.fn = this.container.hooks.helperMissing;
}
}
return typeof helper.fn === 'function'
? helper.fn.call(helper.context, ...helper.params, helper.options)
: helper.fn;
}
private ambiguousBlockValue(block: hbs.AST.BlockStatement, value: any) {
const name = block.path.parts[0];
const helper = this.setupHelper(block, name);
if (!helper.fn) {
value = this.container.hooks.blockHelperMissing!.call(this.context, value, helper.options);
}
return value;
}
private setupHelper(
node: ProcessableNode,
helperName: string
): {
fn?: Handlebars.HelperDelegate;
context: any[];
params: any[];
options: AmbiguousHelperOptions;
} {
private setupHelper(node: ProcessableNode, helperName: string): Helper {
return {
fn: this.container.lookupProperty(this.container.helpers, helperName),
context: this.context,
@ -683,6 +699,8 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
} else {
options.args = this.resolveNodes(decorator.params);
}
} else {
options.args = [];
}
return options;
@ -701,13 +719,12 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
};
if (isBlock(node)) {
// TODO: Is there a way in TypeScript to infer that `options` is `Handlebars.HelperOptions` inside this if-statement. If not, is there a way to just cast once?
(options as Handlebars.HelperOptions).fn = this.generateProgramFunction(node.program);
if (node.program)
this.processDecorators(node.program, (options as Handlebars.HelperOptions).fn);
(options as Handlebars.HelperOptions).inverse = this.generateProgramFunction(node.inverse);
if (node.inverse)
this.processDecorators(node.inverse, (options as Handlebars.HelperOptions).inverse);
(options as Handlebars.HelperOptions).fn = node.program
? this.processDecorators(node.program, this.generateProgramFunction(node.program))
: noop;
(options as Handlebars.HelperOptions).inverse = node.inverse
? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse))
: noop;
}
return options;

View file

@ -38,6 +38,7 @@ export function forEachCompileFunctionName(
class HandlebarsTestBench {
private template: string;
private options: TestOptions;
private beforeRenderFn: Function = () => {};
private compileOptions?: ExtendedCompileOptions;
private runtimeOptions?: ExtendedRuntimeOptions;
private helpers: { [name: string]: Handlebars.HelperDelegate | undefined } = {};
@ -49,6 +50,11 @@ class HandlebarsTestBench {
this.options = options;
}
beforeRender(fn: Function) {
this.beforeRenderFn = fn;
return this;
}
withCompileOptions(compileOptions?: ExtendedCompileOptions) {
this.compileOptions = compileOptions;
return this;
@ -147,6 +153,8 @@ class HandlebarsTestBench {
this.runtimeOptions
);
this.beforeRenderFn();
return renderEval(this.input, runtimeOptions);
}
@ -161,6 +169,8 @@ class HandlebarsTestBench {
this.runtimeOptions
);
this.beforeRenderFn();
return renderAST(this.input, runtimeOptions);
}