From 2b82cb7fa24e019a3717b60abbe0f814b5ddcd5a Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Thu, 2 Feb 2023 20:59:09 +0100 Subject: [PATCH] [@kbn/handlebars] Add support for partials (#150151) Add support for [partials](https://handlebarsjs.com/guide/partials.html) to our own implementation of the [handlebars](https://handlebarsjs.com) template engine. Closes #139068 --- docs/setup/settings.asciidoc | 4 +- packages/kbn-handlebars/README.md | 5 +- packages/kbn-handlebars/index.test.ts | 1 + packages/kbn-handlebars/index.ts | 136 +++- .../kbn-handlebars/src/__jest__/test_bench.ts | 15 + .../src/spec/index.data.test.ts | 12 + .../src/spec/index.partials.test.ts | 593 ++++++++++++++++++ .../src/spec/index.regressions.test.ts | 101 ++- .../src/spec/index.whitespace_control.test.ts | 8 + 9 files changed, 866 insertions(+), 9 deletions(-) create mode 100644 packages/kbn-handlebars/src/spec/index.partials.test.ts diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 3abb513c41d85..3914dfabaf137 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -28,9 +28,9 @@ Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Co `csp.disableUnsafeEval`:: experimental[] Set this to `true` to remove the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#unsafe_eval_expressions[`unsafe-eval`] source expression from the `script-src` directive. *Default: `false`* + -By enabling `csp.disableUnsafeEval`, Kibana will use a custom version of the Handlebars template library which doesn't support https://handlebarsjs.com/guide/partials.html#inline-partials[inline partials]. +By enabling `csp.disableUnsafeEval`, Kibana will use a custom version of the Handlebars template library. Handlebars is used in various locations in the Kibana frontend where custom templates can be supplied by the user when for instance setting up a visualisation. -If you experience any issues rendering Handlebars templates after turning on `csp.disableUnsafeEval`, or if you rely on inline partials, please revert this setting to `false` and https://github.com/elastic/kibana/issues/new/choose[open an issue] in the Kibana GitHub repository. +If you experience any issues rendering Handlebars templates after turning on `csp.disableUnsafeEval`, please revert this setting to `false` and https://github.com/elastic/kibana/issues/new/choose[open an issue] in the Kibana GitHub repository. `csp.worker_src`:: Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src[Content Security Policy `worker-src` directive]. diff --git a/packages/kbn-handlebars/README.md b/packages/kbn-handlebars/README.md index 8528d9803b16d..cc151645ef1be 100644 --- a/packages/kbn-handlebars/README.md +++ b/packages/kbn-handlebars/README.md @@ -11,15 +11,16 @@ A custom version of the handlebars package which, to improve security, does not - `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)) -The [Inline partials](https://handlebarsjs.com/guide/partials.html#inline-partials) handlebars template feature is currently not supported by `@kbn/handlebars`. - ## Implementation differences The standard `handlebars` implementation: diff --git a/packages/kbn-handlebars/index.test.ts b/packages/kbn-handlebars/index.test.ts index 7a3a2a5772c14..bdaebca946533 100644 --- a/packages/kbn-handlebars/index.test.ts +++ b/packages/kbn-handlebars/index.test.ts @@ -432,6 +432,7 @@ describe('blocks', () => { "decorator": [Function], }, "helpers": Object {}, + "partials": Object {}, } `); return `hello ${context.me} ${fn()}!`; diff --git a/packages/kbn-handlebars/index.ts b/packages/kbn-handlebars/index.ts index 2c63e014a3387..bd315841582ce 100644 --- a/packages/kbn-handlebars/index.ts +++ b/packages/kbn-handlebars/index.ts @@ -35,6 +35,7 @@ declare module 'handlebars' { export interface TemplateDelegate { (context?: T, options?: RuntimeOptions): string; // Override to ensure `context` is optional blockParams?: number; // TODO: Can this really be optional? + partials?: any; // TODO: Narrow type to something better than any? } export interface HelperOptions { @@ -47,6 +48,8 @@ declare module 'handlebars' { // eslint-disable-next-line @typescript-eslint/prefer-function-type (...params: any[]): any; } + + export function registerPartial(spec: { [name: string]: Handlebars.Template }): void; // Ensure `spec` object values can be strings } const kHelper = Symbol('helper'); @@ -56,7 +59,10 @@ type NodeType = typeof kHelper | typeof kAmbiguous | typeof kSimple; type LookupProperty = (parent: { [name: string]: any }, propertyName: string) => T; -type ProcessableStatementNode = hbs.AST.MustacheStatement | hbs.AST.SubExpression; +type ProcessableStatementNode = + | hbs.AST.MustacheStatement + | hbs.AST.PartialStatement + | hbs.AST.SubExpression; type ProcessableBlockStatementNode = hbs.AST.BlockStatement | hbs.AST.PartialBlockStatement; type ProcessableNode = ProcessableStatementNode | ProcessableBlockStatementNode; type ProcessableNodeWithPathParts = ProcessableNode & { path: hbs.AST.PathExpression }; @@ -96,7 +102,14 @@ export const compileFnName: 'compile' | 'compileAST' = allowUnsafeEval() ? 'comp */ export type ExtendedCompileOptions = Pick< CompileOptions, - 'data' | 'knownHelpers' | 'knownHelpersOnly' | 'noEscape' | 'strict' | 'assumeObjects' + | 'data' + | 'knownHelpers' + | 'knownHelpersOnly' + | 'noEscape' + | 'strict' + | 'assumeObjects' + | 'preventIndent' + | 'explicitPartialContext' >; /** @@ -107,7 +120,7 @@ export type ExtendedCompileOptions = Pick< */ export type ExtendedRuntimeOptions = Pick< RuntimeOptions, - 'data' | 'helpers' | 'decorators' | 'blockParams' + 'data' | 'helpers' | 'partials' | 'decorators' | 'blockParams' >; /** @@ -127,6 +140,10 @@ export interface HelpersHash { [name: string]: Handlebars.HelperDelegate; } +export interface PartialsHash { + [name: string]: HandlebarsTemplateDelegate; +} + export interface DecoratorsHash { [name: string]: DecoratorFunction; } @@ -173,15 +190,17 @@ Handlebars.compileAST = function ( // If `Handlebars.compileAST` is reassigned, `this` will be undefined. const helpers = (this ?? Handlebars).helpers; + const partials = (this ?? Handlebars).partials; const decorators = (this ?? Handlebars).decorators as DecoratorsHash; - const visitor = new ElasticHandlebarsVisitor(input, options, helpers, decorators); + const visitor = new ElasticHandlebarsVisitor(this, input, options, helpers, partials, decorators); return (context: any, runtimeOptions?: ExtendedRuntimeOptions) => visitor.render(context, runtimeOptions); }; interface Container { helpers: HelpersHash; + partials: PartialsHash; decorators: DecoratorsHash; strict: (obj: { [name: string]: any }, name: string, loc: hbs.AST.SourceLocation) => any; lookupProperty: LookupProperty; @@ -194,12 +213,14 @@ interface Container { } class ElasticHandlebarsVisitor extends Handlebars.Visitor { + private env: typeof Handlebars; private contexts: any[] = []; private output: any[] = []; private template?: string; private compileOptions: ExtendedCompileOptions; private runtimeOptions?: ExtendedRuntimeOptions; private initialHelpers: HelpersHash; + private initialPartials: PartialsHash; private initialDecorators: DecoratorsHash; private blockParamNames: any[][] = []; private blockParamValues: any[][] = []; @@ -210,13 +231,17 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { 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: ExtendedCompileOptions = {}, helpers: HelpersHash, + partials: PartialsHash, decorators: DecoratorsHash ) { super(); + this.env = env; + if (typeof input !== 'string' && input.type === 'Program') { this.ast = input; } else { @@ -246,12 +271,14 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { ); this.initialHelpers = Object.assign({}, helpers); + this.initialPartials = Object.assign({}, partials); this.initialDecorators = Object.assign({}, decorators); const protoAccessControl = createProtoAccessControl({}); const container: Container = (this.container = { helpers: {}, + partials: {}, decorators: {}, strict(obj, name, loc) { if (!obj || !(name in obj)) { @@ -299,6 +326,7 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { this.output = []; this.runtimeOptions = Object.assign({}, options); this.container.helpers = Object.assign(this.initialHelpers, options.helpers); + this.container.partials = Object.assign(this.initialPartials, options.partials); this.container.decorators = Object.assign( this.initialDecorators, options.decorators as DecoratorsHash @@ -379,6 +407,14 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { 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. @@ -631,6 +667,95 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { 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 & Handlebars.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?: Handlebars.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 = Object.assign({}, 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: Handlebars.Template | undefined = + this.container.partials[name] ?? + partialBlock ?? + Handlebars.VM.resolvePartial(undefined, undefined, options); + + 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 as hbs.AST.PartialStatement).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); @@ -747,6 +872,9 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor { ); } + // inherit partials from parent program + runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials; + // stash parent program data const tmpRuntimeOptions = this.runtimeOptions; this.runtimeOptions = runtimeOptions; diff --git a/packages/kbn-handlebars/src/__jest__/test_bench.ts b/packages/kbn-handlebars/src/__jest__/test_bench.ts index ffbf8b1fe84f5..c1407734f4fbe 100644 --- a/packages/kbn-handlebars/src/__jest__/test_bench.ts +++ b/packages/kbn-handlebars/src/__jest__/test_bench.ts @@ -42,6 +42,7 @@ class HandlebarsTestBench { private compileOptions?: ExtendedCompileOptions; private runtimeOptions?: ExtendedRuntimeOptions; private helpers: { [name: string]: Handlebars.HelperDelegate | undefined } = {}; + private partials: { [name: string]: Handlebars.Template } = {}; private decorators: DecoratorsHash = {}; private input: any = {}; @@ -82,6 +83,18 @@ class HandlebarsTestBench { return this; } + withPartial(name: string | number, partial: Handlebars.Template) { + this.partials[name] = partial; + return this; + } + + withPartials(partials: { [name: string]: Handlebars.Template }) { + for (const [name, partial] of Object.entries(partials)) { + this.withPartial(name, partial); + } + return this; + } + withDecorator(name: string, decoratorFunction: DecoratorFunction) { this.decorators[name] = decoratorFunction; return this; @@ -148,6 +161,7 @@ class HandlebarsTestBench { const runtimeOptions: ExtendedRuntimeOptions = Object.assign( { helpers: this.helpers, + partials: this.partials, decorators: this.decorators, }, this.runtimeOptions @@ -164,6 +178,7 @@ class HandlebarsTestBench { const runtimeOptions: ExtendedRuntimeOptions = Object.assign( { helpers: this.helpers, + partials: this.partials, decorators: this.decorators, }, this.runtimeOptions diff --git a/packages/kbn-handlebars/src/spec/index.data.test.ts b/packages/kbn-handlebars/src/spec/index.data.test.ts index d47f5ef41dae8..09712223e503f 100644 --- a/packages/kbn-handlebars/src/spec/index.data.test.ts +++ b/packages/kbn-handlebars/src/spec/index.data.test.ts @@ -134,6 +134,18 @@ describe('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: Handlebars.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 }) diff --git a/packages/kbn-handlebars/src/spec/index.partials.test.ts b/packages/kbn-handlebars/src/spec/index.partials.test.ts new file mode 100644 index 0000000000000..fbcf86f8762bf --- /dev/null +++ b/packages/kbn-handlebars/src/spec/index.partials.test.ts @@ -0,0 +1,593 @@ +/* + * 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: '{{url}}', + }) + .toCompileTo( + 'Dudes: Yehuda http://yehuda Alan http://alan ' + ); + }); + + 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(() => { + const undef: unknown = undefined; + kbnHandlebarsEnv!.registerPartial('undefined_test', undef as Handlebars.Template); + }).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: { + // @ts-expect-error + 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: 'before-block: {{@root/value}} {{> @partial-block }}', + }) + .toCompileTo('before-block: success in-block: success'); + }); + + it('should allow the #each-helper to be used along with partial-blocks', () => { + expectTemplate('') + .withInput({ + value: ['a', 'b', 'c'], + }) + .withPartials({ + list: '{{#each .}}{{> @partial-block}}{{/each}}', + }) + .toCompileTo( + '' + ); + }); + + 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('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}}{{/nested}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks at different nesting levels', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}}{{/nested}}{{> @partial-block}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks at different nesting levels (twice)', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}} {{> @partial-block}}{{/nested}}{{> @partial-block}}+{{> @partial-block}}', + nested: '{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + + it('should render nested partial blocks (twice at each level)', () => { + expectTemplate('') + .withInput({ value: 'success' }) + .withPartials({ + outer: + '{{#> nested}}{{> @partial-block}} {{> @partial-block}}{{/nested}}', + nested: '{{> @partial-block}}{{> @partial-block}}', + }) + .toCompileTo( + '' + ); + }); + }); + + 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}}{{>@partial-block}}{{/inner}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo('success'); + }); + + it('should render nested inline partials with partial-blocks on different nesting levels', () => { + expectTemplate( + '{{#*inline "outer"}}{{#>inner}}{{>@partial-block}}{{/inner}}{{>@partial-block}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo('successsuccess'); + }); + + it('should render nested inline partials (twice at each level)', () => { + expectTemplate( + '{{#*inline "outer"}}{{#>inner}}{{>@partial-block}} {{>@partial-block}}{{/inner}}{{/inline}}' + + '{{#*inline "inner"}}{{>@partial-block}}{{>@partial-block}}{{/inline}}' + + '{{#>outer}}{{value}}{{/outer}}' + ) + .withInput({ value: 'success' }) + .toCompileTo( + 'success successsuccess success' + ); + }); + }); + + 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/packages/kbn-handlebars/src/spec/index.regressions.test.ts b/packages/kbn-handlebars/src/spec/index.regressions.test.ts index 92e6653bb2be7..b6785372e5fc5 100644 --- a/packages/kbn-handlebars/src/spec/index.regressions.test.ts +++ b/packages/kbn-handlebars/src/spec/index.regressions.test.ts @@ -6,7 +6,7 @@ */ import Handlebars from '../..'; -import { expectTemplate } from '../__jest__/test_bench'; +import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench'; describe('Regressions', () => { it('GH-94: Cannot read property of undefined', () => { @@ -201,6 +201,19 @@ describe('Regressions', () => { .toCompileTo('Key: \nKey: name\nKey: value\n'); }); + it('GH-1054: Should handle simple safe string responses', () => { + expectTemplate('{{#wrap}}{{>partial}}{{/wrap}}') + .withHelpers({ + wrap(options: Handlebars.HelperOptions) { + return new Handlebars.SafeString(options.fn()); + }, + }) + .withPartials({ + partial: '{{#wrap}}{{/wrap}}', + }) + .toCompileTo(''); + }); + it('GH-1065: Sparse arrays', () => { const array = []; array[1] = 'foo'; @@ -229,6 +242,34 @@ describe('Regressions', () => { .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' + @@ -249,6 +290,19 @@ describe('Regressions', () => { .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({ @@ -258,6 +312,15 @@ describe('Regressions', () => { .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' }) @@ -269,6 +332,42 @@ describe('Regressions', () => { .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}}') diff --git a/packages/kbn-handlebars/src/spec/index.whitespace_control.test.ts b/packages/kbn-handlebars/src/spec/index.whitespace_control.test.ts index 901dd1025fdee..1f7cf019c3513 100644 --- a/packages/kbn-handlebars/src/spec/index.whitespace_control.test.ts +++ b/packages/kbn-handlebars/src/spec/index.whitespace_control.test.ts @@ -72,6 +72,14 @@ describe('whitespace control', () => { }); }); + 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' })