Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Feb 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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