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

[@kbn/handlebars] Add support for partials #150151

Merged
merged 3 commits into from
Feb 2, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
139 changes: 135 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,98 @@ 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 =
watson marked this conversation as resolved.
Show resolved Hide resolved
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);

// TypeScript note: The `partial` object can be either `hbs.AST.PartialStatement` or ` as hbs.AST.PartialBlockStatement`.
// The `indent` property only exists on the former, so it will always be `undefined` on the latter, which isn't an issue.
// So to satisfy TypeScript I cast it to `hbs.AST.PartialStatement`. I wish there were a better approach.
if ((partial as hbs.AST.PartialStatement).indent) {
result =
(partial as hbs.AST.PartialStatement).indent +
(this.compileOptions.preventIndent
? result
: result.replace(/\n(?!$)/g, `\n${(partial as hbs.AST.PartialStatement).indent}`)); // indent each line, ignoring any trailing linebreak
}
watson marked this conversation as resolved.
Show resolved Hide resolved

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 +875,9 @@ class ElasticHandlebarsVisitor extends Handlebars.Visitor {
);
}

// inherit partials from parent program
runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional nit: I see we have multiple places in this function where we rely on !, wondering if it'd make sense to move this.runtimeOptions! to a local variable and use it instead? Unless I'm missing something...

      // It should be defined because ...
      const currentRuntimeOptions = this.runtimeOptions!;
runtimeOptions.data = runtimeOptions.data || currentRuntimeOptions.data;
      if (runtimeOptions.blockParams) {
        runtimeOptions.blockParams = runtimeOptions.blockParams.concat(
          currentRuntimeOptions.blockParams
        );
      }

      // inherit partials from parent program
      runtimeOptions.partials = runtimeOptions.partials || currentRuntimeOptions.partials;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's because runtimeOptions isn't set in the constructor, but in render, which technically means it's possible to get to this point in the code before it's set. But only theoretically as render is ALWAYS called before anything else, so we can just treat it as if it is always set.

We use this.runtimeOptions! all over the code, not only in this function, so I would prefer an approach that satisfied the entire class than to simply add a local variable in each function. Otherwise I prefer the ! approach.


// 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is there a valid use case for name to be a number?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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