Skip to content

Commit

Permalink
[@kbn/handlebars] Add support for partials (#150151)
Browse files Browse the repository at this point in the history
Add support for [partials](https://handlebarsjs.com/guide/partials.html)
to our own implementation of the [handlebars](https://handlebarsjs.com)
template engine.

Closes #139068
  • Loading branch information
Thomas Watson authored Feb 2, 2023
1 parent 62e0d0e commit 2b82cb7
Show file tree
Hide file tree
Showing 9 changed files with 866 additions and 9 deletions.
4 changes: 2 additions & 2 deletions docs/setup/settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
5 changes: 3 additions & 2 deletions packages/kbn-handlebars/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-handlebars/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ describe('blocks', () => {
"decorator": [Function],
},
"helpers": Object {},
"partials": Object {},
}
`);
return `hello ${context.me} ${fn()}!`;
Expand Down
136 changes: 132 additions & 4 deletions packages/kbn-handlebars/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand All @@ -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 };
Expand Down Expand Up @@ -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'
>;

/**
Expand All @@ -107,7 +120,7 @@ export type ExtendedCompileOptions = Pick<
*/
export type ExtendedRuntimeOptions = Pick<
RuntimeOptions,
'data' | 'helpers' | 'decorators' | 'blockParams'
'data' | 'helpers' | 'partials' | 'decorators' | 'blockParams'
>;

/**
Expand All @@ -127,6 +140,10 @@ export interface HelpersHash {
[name: string]: Handlebars.HelperDelegate;
}

export interface PartialsHash {
[name: string]: HandlebarsTemplateDelegate;
}

export interface DecoratorsHash {
[name: string]: DecoratorFunction;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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[][] = [];
Expand All @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/kbn-handlebars/src/__jest__/test_bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +161,7 @@ class HandlebarsTestBench {
const runtimeOptions: ExtendedRuntimeOptions = Object.assign(
{
helpers: this.helpers,
partials: this.partials,
decorators: this.decorators,
},
this.runtimeOptions
Expand All @@ -164,6 +178,7 @@ class HandlebarsTestBench {
const runtimeOptions: ExtendedRuntimeOptions = Object.assign(
{
helpers: this.helpers,
partials: this.partials,
decorators: this.decorators,
},
this.runtimeOptions
Expand Down
12 changes: 12 additions & 0 deletions packages/kbn-handlebars/src/spec/index.data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading

0 comments on commit 2b82cb7

Please sign in to comment.