mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.6`: - [[@kbn/handlebars] Support custom decorator return value (#149392)](https://github.com/elastic/kibana/pull/149392) <!--- 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":"2023-02-01T10:57:22Z","message":"[@kbn/handlebars] Support custom decorator return value (#149392)\n\nFixes #149327","sha":"f296abb6c93de1b98c3dab2dc61c7eef66c691ba","branchLabelMapping":{"^v8.7.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:prev-minor","v8.7.0"],"number":149392,"url":"https://github.com/elastic/kibana/pull/149392","mergeCommit":{"message":"[@kbn/handlebars] Support custom decorator return value (#149392)\n\nFixes #149327","sha":"f296abb6c93de1b98c3dab2dc61c7eef66c691ba"}},"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/149392","number":149392,"mergeCommit":{"message":"[@kbn/handlebars] Support custom decorator return value (#149392)\n\nFixes #149327","sha":"f296abb6c93de1b98c3dab2dc61c7eef66c691ba"}}]}] BACKPORT--> Co-authored-by: Thomas Watson <watson@elastic.co>
This commit is contained in:
parent
fd93843e5b
commit
97c50cbc8a
3 changed files with 297 additions and 83 deletions
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue