[8.6] [@kbn/handlebars] Add support for partials (#150151) (#150223)

# Backport

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

<!--- 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-02T19:59:09Z","message":"[@kbn/handlebars]
Add support for partials (#150151)\n\nAdd support for
[partials](https://handlebarsjs.com/guide/partials.html)\r\nto our own
implementation of the [handlebars](https://handlebarsjs.com)\r\ntemplate
engine.\r\n\r\nCloses
#139068","sha":"2b82cb7fa24e019a3717b60abbe0f814b5ddcd5a","branchLabelMapping":{"^v8.7.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","backport:prev-minor","v8.7.0"],"number":150151,"url":"https://github.com/elastic/kibana/pull/150151","mergeCommit":{"message":"[@kbn/handlebars]
Add support for partials (#150151)\n\nAdd support for
[partials](https://handlebarsjs.com/guide/partials.html)\r\nto our own
implementation of the [handlebars](https://handlebarsjs.com)\r\ntemplate
engine.\r\n\r\nCloses
#139068","sha":"2b82cb7fa24e019a3717b60abbe0f814b5ddcd5a"}},"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/150151","number":150151,"mergeCommit":{"message":"[@kbn/handlebars]
Add support for partials (#150151)\n\nAdd support for
[partials](https://handlebarsjs.com/guide/partials.html)\r\nto our own
implementation of the [handlebars](https://handlebarsjs.com)\r\ntemplate
engine.\r\n\r\nCloses
#139068","sha":"2b82cb7fa24e019a3717b60abbe0f814b5ddcd5a"}}]}]
BACKPORT-->

Co-authored-by: Thomas Watson <watson@elastic.co>
This commit is contained in:
Kibana Machine 2023-02-02 16:06:52 -05:00 committed by GitHub
parent 6c767922d8
commit 0659916073
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 866 additions and 9 deletions

View file

@ -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].

View file

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

View file

@ -432,6 +432,7 @@ describe('blocks', () => {
"decorator": [Function],
},
"helpers": Object {},
"partials": Object {},
}
`);
return `hello ${context.me} ${fn()}!`;

View file

@ -35,6 +35,7 @@ declare module 'handlebars' {
export interface TemplateDelegate<T = any> {
(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 = <T = any>(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;

View file

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

View file

@ -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 })

View file

@ -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: '<a href="{{url}}">{{url}}</a>',
})
.toCompileTo(
'Dudes: Yehuda <a href="http://yehuda">http://yehuda</a> Alan <a href="http://alan">http://alan</a> '
);
});
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: '<code>before-block: {{@root/value}} {{> @partial-block }}</code>',
})
.toCompileTo('<code>before-block: success in-block: success</code>');
});
it('should allow the #each-helper to be used along with partial-blocks', () => {
expectTemplate('<template>{{#> list value}}value = {{.}}{{/list}}</template>')
.withInput({
value: ['a', 'b', 'c'],
})
.withPartials({
list: '<list>{{#each .}}<item>{{> @partial-block}}</item>{{/each}}</list>',
})
.toCompileTo(
'<template><list><item>value = a</item><item>value = b</item><item>value = c</item></list></template>'
);
});
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('<template>{{#> outer}}{{value}}{{/outer}}</template>')
.withInput({ value: 'success' })
.withPartials({
outer:
'<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}</outer>',
nested: '<nested>{{> @partial-block}}</nested>',
})
.toCompileTo(
'<template><outer><nested><outer-block>success</outer-block></nested></outer></template>'
);
});
it('should render nested partial blocks at different nesting levels', () => {
expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
.withInput({ value: 'success' })
.withPartials({
outer:
'<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}</outer>',
nested: '<nested>{{> @partial-block}}</nested>',
})
.toCompileTo(
'<template><outer><nested><outer-block>success</outer-block></nested>success</outer></template>'
);
});
it('should render nested partial blocks at different nesting levels (twice)', () => {
expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
.withInput({ value: 'success' })
.withPartials({
outer:
'<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}+{{> @partial-block}}</outer>',
nested: '<nested>{{> @partial-block}}</nested>',
})
.toCompileTo(
'<template><outer><nested><outer-block>success success</outer-block></nested>success+success</outer></template>'
);
});
it('should render nested partial blocks (twice at each level)', () => {
expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>')
.withInput({ value: 'success' })
.withPartials({
outer:
'<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}</outer>',
nested: '<nested>{{> @partial-block}}{{> @partial-block}}</nested>',
})
.toCompileTo(
'<template><outer>' +
'<nested><outer-block>success success</outer-block><outer-block>success success</outer-block></nested>' +
'</outer></template>'
);
});
});
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}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' +
'{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' +
'{{#>outer}}{{value}}{{/outer}}'
)
.withInput({ value: 'success' })
.toCompileTo('<inner><outer-block>success</outer-block></inner>');
});
it('should render nested inline partials with partial-blocks on different nesting levels', () => {
expectTemplate(
'{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{>@partial-block}}{{/inline}}' +
'{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' +
'{{#>outer}}{{value}}{{/outer}}'
)
.withInput({ value: 'success' })
.toCompileTo('<inner><outer-block>success</outer-block></inner>success');
});
it('should render nested inline partials (twice at each level)', () => {
expectTemplate(
'{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}} {{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' +
'{{#*inline "inner"}}<inner>{{>@partial-block}}{{>@partial-block}}</inner>{{/inline}}' +
'{{#>outer}}{{value}}{{/outer}}'
)
.withInput({ value: 'success' })
.toCompileTo(
'<inner><outer-block>success success</outer-block><outer-block>success success</outer-block></inner>'
);
});
});
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');
});
});
});

View file

@ -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}}<partial>{{/wrap}}',
})
.toCompileTo('<partial>');
});
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}}')

View file

@ -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' })