diff --git a/packages/kbn-handlebars/index.ts b/packages/kbn-handlebars/index.ts index bd315841582ce..a00dbdbb37a64 100644 --- a/packages/kbn-handlebars/index.ts +++ b/packages/kbn-handlebars/index.ts @@ -3,150 +3,8 @@ * See `packages/kbn-handlebars/LICENSE` for more information. */ -// The handlebars module uses `export =`, so we should technically use `import Handlebars = require('handlebars')`, but Babel will not allow this. -import Handlebars from 'handlebars'; -import { - createProtoAccessControl, - resultIsAllowed, - // @ts-expect-error: Could not find a declaration file for module -} from 'handlebars/dist/cjs/handlebars/internal/proto-access'; -// @ts-expect-error: Could not find a declaration file for module -import AST from 'handlebars/dist/cjs/handlebars/compiler/ast'; -// @ts-expect-error: Could not find a declaration file for module -import { indexOf, createFrame } from 'handlebars/dist/cjs/handlebars/utils'; -// @ts-expect-error: Could not find a declaration file for module -import { moveHelperToHooks } from 'handlebars/dist/cjs/handlebars/helpers'; - -const originalCreate = Handlebars.create; - -/** - * A custom version of the Handlesbars module with an extra `compileAST` function and fixed typings. - */ -declare module 'handlebars' { - export function compileAST( - input: string | hbs.AST.Program, - options?: ExtendedCompileOptions - ): (context?: any, options?: ExtendedRuntimeOptions) => string; - - // -------------------------------------------------------- - // Override/Extend inherited types below that are incorrect - // -------------------------------------------------------- - - 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 { - name: string; - loc: { start: hbs.AST.SourceLocation['start']; end: hbs.AST.SourceLocation['end'] }; - lookupProperty: LookupProperty; - } - - export interface HelperDelegate { - // 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'); -const kAmbiguous = Symbol('ambiguous'); -const kSimple = Symbol('simple'); -type NodeType = typeof kHelper | typeof kAmbiguous | typeof kSimple; - -type LookupProperty = (parent: { [name: string]: any }, propertyName: string) => T; - -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 }; -type ProcessableNodeWithPathPartsOrLiteral = ProcessableNode & { - path: hbs.AST.PathExpression | hbs.AST.Literal; -}; - -interface Helper { - fn?: Handlebars.HelperDelegate; - context: any[]; - params: any[]; - options: AmbiguousHelperOptions; -} - -export type NonBlockHelperOptions = Omit; -export type AmbiguousHelperOptions = Handlebars.HelperOptions | NonBlockHelperOptions; - -export interface DecoratorOptions extends Omit { - args?: any[]; -} - -/** - * If the `unsafe-eval` CSP is set, this string constant will be `compile`, - * otherwise `compileAST`. - * - * This can be used to call the more optimized `compile` function in - * environments that support it, or fall back to `compileAST` on environments - * that don't. - */ -export const compileFnName: 'compile' | 'compileAST' = allowUnsafeEval() ? 'compile' : 'compileAST'; - -/** - * Supported Handlebars compile options. - * - * This is a subset of all the compile options supported by the upstream - * Handlebars module. - */ -export type ExtendedCompileOptions = Pick< - CompileOptions, - | 'data' - | 'knownHelpers' - | 'knownHelpersOnly' - | 'noEscape' - | 'strict' - | 'assumeObjects' - | 'preventIndent' - | 'explicitPartialContext' ->; - -/** - * Supported Handlebars runtime options - * - * This is a subset of all the runtime options supported by the upstream - * Handlebars module. - */ -export type ExtendedRuntimeOptions = Pick< - RuntimeOptions, - 'data' | 'helpers' | 'partials' | 'decorators' | 'blockParams' ->; - -/** - * According to the [decorator docs]{@link https://github.com/handlebars-lang/handlebars.js/blob/4.x/docs/decorators-api.md}, - * a decorator will be called with a different set of arugments than what's actually happening in the upstream code. - * So here I assume that the docs are wrong and that the upstream code is correct. In reality, `context` is the last 4 - * documented arguments rolled into one object. - */ -export type DecoratorFunction = ( - prog: Handlebars.TemplateDelegate, - props: Record, - container: Container, - options: any -) => any; - -export interface HelpersHash { - [name: string]: Handlebars.HelperDelegate; -} - -export interface PartialsHash { - [name: string]: HandlebarsTemplateDelegate; -} - -export interface DecoratorsHash { - [name: string]: DecoratorFunction; -} +import { Handlebars } from './src/handlebars'; +import { allowUnsafeEval } from './src/utils'; // The handlebars module uses `export =`, so it can't be re-exported using `export *`. // However, because of Babel, we're not allowed to use `export =` ourselves. @@ -156,866 +14,17 @@ export interface DecoratorsHash { export default Handlebars; /** - * Creates an isolated Handlebars environment. - * - * Each environment has its own helpers. - * This is only necessary for use cases that demand distinct helpers. - * Most use cases can use the root Handlebars environment directly. - * - * @returns A sandboxed/scoped version of the @kbn/handlebars module - */ -export function create(): typeof Handlebars { - const SandboxedHandlebars = originalCreate.call(Handlebars) as typeof Handlebars; - // When creating new Handlebars environments, ensure the custom compileAST function is present in the new environment as well - SandboxedHandlebars.compileAST = Handlebars.compileAST; - return SandboxedHandlebars; -} - -Handlebars.create = create; - -/** - * Compiles the given Handlbars template without the use of `eval`. + * If the `unsafe-eval` CSP is set, this string constant will be `compile`, + * otherwise `compileAST`. * - * @returns A render function with the same API as the return value from the regular Handlebars `compile` function. + * This can be used to call the more optimized `compile` function in + * environments that support it, or fall back to `compileAST` on environments + * that don't. */ -Handlebars.compileAST = function ( - input: string | hbs.AST.Program, - options?: ExtendedCompileOptions -) { - if (input == null || (typeof input !== 'string' && input.type !== 'Program')) { - throw new Handlebars.Exception( - `You must pass a string or Handlebars AST to Handlebars.compileAST. You passed ${input}` - ); - } - - // 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(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; - lambda: (current: any, context: any) => any; - data: (value: any, depth: number) => any; - hooks: { - helperMissing?: Handlebars.HelperDelegate; - blockHelperMissing?: Handlebars.HelperDelegate; - }; -} - -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[][] = []; - private ast?: hbs.AST.Program; - private container: Container; - private defaultHelperOptions: Pick; - private processedRootDecorators = false; // Root decorators should not have access to input arguments. This flag helps us detect them. - 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 { - this.template = input as string; - } - - this.compileOptions = Object.assign( - { - data: true, - }, - options - ); - - this.compileOptions.knownHelpers = Object.assign( - Object.create(null), - { - helperMissing: true, - blockHelperMissing: true, - each: true, - if: true, - unless: true, - with: true, - log: true, - lookup: true, - }, - this.compileOptions.knownHelpers - ); - - 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)) { - throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, { - loc, - } as hbs.AST.Node); - } - return container.lookupProperty(obj, name); - }, - // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) - lookupProperty(parent, propertyName) { - const result = parent[propertyName]; - if (result == null) { - return result; - } - if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { - return result; - } - - if (resultIsAllowed(result, protoAccessControl, propertyName)) { - return result; - } - return undefined; - }, - // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) - lambda(current, context) { - return typeof current === 'function' ? current.call(context) : current; - }, - data(value: any, depth: number) { - while (value && depth--) { - value = value._parent; - } - return value; - }, - hooks: {}, - }); - - this.defaultHelperOptions = { - lookupProperty: container.lookupProperty, - }; - } - - render(context: any, options: ExtendedRuntimeOptions = {}): string { - this.contexts = [context]; - 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 - ); - this.container.hooks = {}; - this.processedRootDecorators = false; - this.processedDecoratorsForProgram.clear(); - - if (this.compileOptions.data) { - this.runtimeOptions.data = initData(context, this.runtimeOptions.data); - } - - const keepHelperInHelpers = false; - moveHelperToHooks(this.container, 'helperMissing', keepHelperInHelpers); - moveHelperToHooks(this.container, 'blockHelperMissing', keepHelperInHelpers); - - if (!this.ast) { - this.ast = Handlebars.parse(this.template!); - } - - // The `defaultMain` function contains the default behavior: - // - // Generate a "program" function based on the root `Program` in the AST and - // call it. This will start the processing of all the child nodes in the - // AST. - const defaultMain: Handlebars.TemplateDelegate = (_context) => { - const prog = this.generateProgramFunction(this.ast!); - return prog(_context, this.runtimeOptions); - }; - - // Run any decorators that might exist on the root: - // - // The `defaultMain` function is passed in, and if there are no root - // decorators, or if the decorators chooses to do so, the same function is - // returned from `processDecorators` and the default behavior is retained. - // - // Alternatively any of the root decorators might call the `defaultMain` - // function themselves, process its return value, and return a completely - // different `main` function. - const main = this.processDecorators(this.ast, defaultMain); - this.processedRootDecorators = true; - - // Call the `main` function and add the result to the final output. - const result = main(this.context, options); - - if (main === defaultMain) { - this.output.push(result); - return this.output.join(''); - } else { - // We normally expect the return value of `main` to be a string. However, - // if a decorator is used to override the `defaultMain` function, the - // return value can be any type. To match the upstream handlebars project - // behavior, we want the result of rendering the template to be the - // literal value returned by the decorator. - // - // Since the output array in this case always will be empty, we just - // return that single value instead of attempting to join all the array - // elements as strings. - return result; - } - } - - // ********************************************** // - // *** Visitor AST Traversal Functions *** // - // ********************************************** // - - Program(program: hbs.AST.Program) { - this.blockParamNames.unshift(program.blockParams); - super.Program(program); - this.blockParamNames.shift(); - } - - MustacheStatement(mustache: hbs.AST.MustacheStatement) { - this.processStatementOrExpression(mustache); - } - - BlockStatement(block: hbs.AST.BlockStatement) { - 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. - DecoratorBlock(decorator: hbs.AST.DecoratorBlock) {} - - // 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. - Decorator(decorator: hbs.AST.Decorator) {} - - SubExpression(sexpr: hbs.AST.SubExpression) { - this.processStatementOrExpression(sexpr); - } - - PathExpression(path: hbs.AST.PathExpression) { - const blockParamId = - !path.depth && !AST.helpers.scopedId(path) && this.blockParamIndex(path.parts[0]); - - let result; - if (blockParamId) { - result = this.lookupBlockParam(blockParamId, path); - } else if (path.data) { - result = this.lookupData(this.runtimeOptions!.data, path); - } else { - result = this.resolvePath(this.contexts[path.depth], path); - } - - this.output.push(result); - } - - ContentStatement(content: hbs.AST.ContentStatement) { - this.output.push(content.value); - } - - StringLiteral(string: hbs.AST.StringLiteral) { - this.output.push(string.value); - } - - NumberLiteral(number: hbs.AST.NumberLiteral) { - this.output.push(number.value); - } - - BooleanLiteral(bool: hbs.AST.BooleanLiteral) { - this.output.push(bool.value); - } - - UndefinedLiteral() { - this.output.push(undefined); - } - - NullLiteral() { - this.output.push(null); - } - - // ********************************************** // - // *** Visitor AST Helper Functions *** // - // ********************************************** // - - /** - * Special code for decorators, since they have to be executed ahead of time (before the wrapping program). - * So we have to look into the program AST body and see if it contains any decorators that we have to process - * before we can finish processing of the wrapping program. - */ - private processDecorators(program: hbs.AST.Program, prog: Handlebars.TemplateDelegate) { - if (!this.processedDecoratorsForProgram.has(program)) { - this.processedDecoratorsForProgram.add(program); - const props = {}; - for (const node of program.body) { - if (isDecorator(node)) { - prog = this.processDecorator(node, prog, props); - } - } - } - - return prog; - } - - private processDecorator( - decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator, - prog: Handlebars.TemplateDelegate, - props: Record - ) { - const options = this.setupDecoratorOptions(decorator); - - const result = this.container.lookupProperty( - this.container.decorators, - options.name - )(prog, props, this.container, options); - - return Object.assign(result || prog, props); - } - - private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) { - // Calling `transformLiteralToPath` has side-effects! - // It converts a node from type `ProcessableNodeWithPathPartsOrLiteral` to `ProcessableNodeWithPathParts` - transformLiteralToPath(node); - - switch (this.classifyNode(node as ProcessableNodeWithPathParts)) { - case kSimple: - this.processSimpleNode(node as ProcessableNodeWithPathParts); - break; - case kHelper: - this.processHelperNode(node as ProcessableNodeWithPathParts); - break; - case kAmbiguous: - this.processAmbiguousNode(node as ProcessableNodeWithPathParts); - break; - } - } - - // Liftet from lib/handlebars/compiler/compiler.js (original name: classifySexpr) - private classifyNode(node: { path: hbs.AST.PathExpression }): NodeType { - const isSimple = AST.helpers.simpleId(node.path); - const isBlockParam = isSimple && !!this.blockParamIndex(node.path.parts[0]); - - // a mustache is an eligible helper if: - // * its id is simple (a single part, not `this` or `..`) - let isHelper = !isBlockParam && AST.helpers.helperExpression(node); - - // if a mustache is an eligible helper but not a definite - // helper, it is ambiguous, and will be resolved in a later - // pass or at runtime. - let isEligible = !isBlockParam && (isHelper || isSimple); - - // if ambiguous, we can possibly resolve the ambiguity now - // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc. - if (isEligible && !isHelper) { - const name = node.path.parts[0]; - const options = this.compileOptions; - if (options.knownHelpers && options.knownHelpers[name]) { - isHelper = true; - } else if (options.knownHelpersOnly) { - isEligible = false; - } - } - - if (isHelper) { - return kHelper; - } else if (isEligible) { - return kAmbiguous; - } else { - return kSimple; - } - } - - // Liftet from lib/handlebars/compiler/compiler.js - private blockParamIndex(name: string): [number, any] | undefined { - for (let depth = 0, len = this.blockParamNames.length; depth < len; depth++) { - const blockParams = this.blockParamNames[depth]; - const param = blockParams && indexOf(blockParams, name); - if (blockParams && param >= 0) { - return [depth, param]; - } - } - } - - // Looks up the value of `parts` on the given block param and pushes - // it onto the stack. - private lookupBlockParam(blockParamId: [number, any], path: hbs.AST.PathExpression) { - const value = this.blockParamValues[blockParamId[0]][blockParamId[1]]; - return this.resolvePath(value, path, 1); - } - - // Push the data lookup operator - private lookupData(data: any, path: hbs.AST.PathExpression) { - if (path.depth) { - data = this.container.data(data, path.depth); - } - - return this.resolvePath(data, path); - } - - private processSimpleNode(node: ProcessableNodeWithPathParts) { - const path = node.path; - // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars - path.strict = true; - const result = this.resolveNodes(path)[0]; - const lambdaResult = this.container.lambda(result, this.context); - - if (isBlock(node)) { - this.blockValue(node, lambdaResult); - } else { - this.output.push(lambdaResult); - } - } - - // The purpose of this opcode is to take a block of the form - // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and - // replace it on the stack with the result of properly - // invoking blockHelperMissing. - private blockValue(node: hbs.AST.BlockStatement, value: any) { - const name = node.path.original; - const options = this.setupParams(node, name); - - const result = this.container.hooks.blockHelperMissing!.call(this.context, value, options); - - this.output.push(result); - } - - private processHelperNode(node: ProcessableNodeWithPathParts) { - const path = node.path; - const name = path.parts[0]; - - if (this.compileOptions.knownHelpers && this.compileOptions.knownHelpers[name]) { - this.invokeKnownHelper(node); - } else if (this.compileOptions.knownHelpersOnly) { - throw new Handlebars.Exception( - 'You specified knownHelpersOnly, but used the unknown helper ' + name, - node - ); - } else { - this.invokeHelper(node); - } - } - - // This operation is used when the helper is known to exist, - // so a `helperMissing` fallback is not required. - private invokeKnownHelper(node: ProcessableNodeWithPathParts) { - const name = node.path.parts[0]; - const helper = this.setupHelper(node, name); - // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards - const result = helper.fn!.call(helper.context, ...helper.params, helper.options); - this.output.push(result); - } - - // Pops off the helper's parameters, invokes the helper, - // and pushes the helper's return value onto the stack. - // - // If the helper is not found, `helperMissing` is called. - private invokeHelper(node: ProcessableNodeWithPathParts) { - const path = node.path; - const name = path.original; - const isSimple = AST.helpers.simpleId(path); - const helper = this.setupHelper(node, name); - - const loc = isSimple && helper.fn ? node.loc : path.loc; - helper.fn = (isSimple && helper.fn) || this.resolveNodes(path)[0]; - - if (!helper.fn) { - if (this.compileOptions.strict) { - helper.fn = this.container.strict(helper.context, name, loc); - } else { - helper.fn = this.container.hooks.helperMissing; - } - } - - // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards - const result = helper.fn!.call(helper.context, ...helper.params, helper.options); - - 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); - let { fn: helperFn } = helper; - - const loc = helperFn ? node.loc : node.path.loc; - helperFn = helperFn ?? this.resolveNodes(node.path)[0]; - - if (helperFn === undefined) { - if (this.compileOptions.strict) { - helperFn = this.container.strict(helper.context, name, loc); - } else { - helperFn = - helper.context != null - ? this.container.lookupProperty(helper.context, name) - : helper.context; - if (helperFn == null) helperFn = this.container.hooks.helperMissing; - } - } - - const helperResult = - typeof helperFn === 'function' - ? helperFn.call(helper.context, ...helper.params, helper.options) - : helperFn; - - if (isBlock(node)) { - const result = helper.fn - ? helperResult - : this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options); - if (result != null) { - this.output.push(result); - } - } else { - if ( - (node as hbs.AST.MustacheStatement).escaped === false || - this.compileOptions.noEscape === true || - typeof helperResult !== 'string' - ) { - this.output.push(helperResult); - } else { - this.output.push(Handlebars.escapeExpression(helperResult)); - } - } - } - - private setupHelper(node: ProcessableNode, helperName: string): Helper { - return { - fn: this.container.lookupProperty(this.container.helpers, helperName), - context: this.context, - params: this.resolveNodes(node.params), - options: this.setupParams(node, helperName), - }; - } - - private setupDecoratorOptions(decorator: hbs.AST.Decorator | hbs.AST.DecoratorBlock) { - // TypeScript: The types indicate that `decorator.path` technically can be an `hbs.AST.Literal`. However, the upstream codebase always treats it as an `hbs.AST.PathExpression`, so we do too. - const name = (decorator.path as hbs.AST.PathExpression).original; - const options = toDecoratorOptions(this.setupParams(decorator, name)); - - if (decorator.params.length > 0) { - if (!this.processedRootDecorators) { - // When processing the root decorators, temporarily remove the root context so it's not accessible to the decorator - const context = this.contexts.shift(); - options.args = this.resolveNodes(decorator.params); - this.contexts.unshift(context); - } else { - options.args = this.resolveNodes(decorator.params); - } - } else { - options.args = []; - } - - return options; - } - - private setupParams(node: ProcessableBlockStatementNode, name: string): Handlebars.HelperOptions; - private setupParams(node: ProcessableStatementNode, name: string): NonBlockHelperOptions; - private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions; - private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions { - const options = { - name, - hash: this.getHash(node), - data: this.runtimeOptions!.data, - loc: { start: node.loc.start, end: node.loc.end }, - ...this.defaultHelperOptions, - }; - - if (isBlock(node)) { - (options as Handlebars.HelperOptions).fn = node.program - ? this.processDecorators(node.program, this.generateProgramFunction(node.program)) - : noop; - (options as Handlebars.HelperOptions).inverse = node.inverse - ? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse)) - : noop; - } - - return options; - } - - private generateProgramFunction(program: hbs.AST.Program) { - if (!program) return noop; - - const prog: Handlebars.TemplateDelegate = ( - nextContext: any, - runtimeOptions: ExtendedRuntimeOptions = {} - ) => { - runtimeOptions = Object.assign({}, runtimeOptions); - - // inherit data in blockParams from parent program - runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data; - if (runtimeOptions.blockParams) { - runtimeOptions.blockParams = runtimeOptions.blockParams.concat( - this.runtimeOptions!.blockParams - ); - } - - // inherit partials from parent program - runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials; - - // stash parent program data - const tmpRuntimeOptions = this.runtimeOptions; - this.runtimeOptions = runtimeOptions; - const shiftContext = nextContext !== this.context; - if (shiftContext) this.contexts.unshift(nextContext); - this.blockParamValues.unshift(runtimeOptions.blockParams || []); - - // execute child program - const result = this.resolveNodes(program).join(''); - - // unstash parent program data - this.blockParamValues.shift(); - if (shiftContext) this.contexts.shift(); - this.runtimeOptions = tmpRuntimeOptions; - - // return result of child program - return result; - }; - - prog.blockParams = program.blockParams?.length ?? 0; - return prog; - } - - private getHash(statement: { hash?: hbs.AST.Hash }) { - const result: { [key: string]: any } = {}; - if (!statement.hash) return result; - for (const { key, value } of statement.hash.pairs) { - result[key] = this.resolveNodes(value)[0]; - } - return result; - } - - private resolvePath(obj: any, path: hbs.AST.PathExpression, index = 0) { - if (this.compileOptions.strict || this.compileOptions.assumeObjects) { - return this.strictLookup(obj, path); - } - - for (; index < path.parts.length; index++) { - if (obj == null) return; - obj = this.container.lookupProperty(obj, path.parts[index]); - } - - return obj; - } - - private strictLookup(obj: any, path: hbs.AST.PathExpression) { - // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars - const requireTerminal = this.compileOptions.strict && path.strict; - const len = path.parts.length - (requireTerminal ? 1 : 0); - - for (let i = 0; i < len; i++) { - obj = this.container.lookupProperty(obj, path.parts[i]); - } - - if (requireTerminal) { - return this.container.strict(obj, path.parts[len], path.loc); - } else { - return obj; - } - } - - private resolveNodes(nodes: hbs.AST.Node | hbs.AST.Node[]): any[] { - const currentOutput = this.output; - this.output = []; - - if (Array.isArray(nodes)) { - this.acceptArray(nodes); - } else { - this.accept(nodes); - } - - const result = this.output; - - this.output = currentOutput; - - return result; - } - - private get context() { - return this.contexts[0]; - } -} - -// ********************************************** // -// *** Utilily Functions *** // -// ********************************************** // - -function isBlock(node: hbs.AST.Node): node is hbs.AST.BlockStatement { - return 'program' in node || 'inverse' in node; -} - -function isDecorator(node: hbs.AST.Node): node is hbs.AST.Decorator | hbs.AST.DecoratorBlock { - return node.type === 'Decorator' || node.type === 'DecoratorBlock'; -} - -function toDecoratorOptions(options: AmbiguousHelperOptions) { - // There's really no tests/documentation on this, but to match the upstream codebase we'll remove `lookupProperty` from the decorator context - delete (options as any).lookupProperty; - - return options as DecoratorOptions; -} - -function noop() { - return ''; -} - -// liftet from handlebars lib/handlebars/runtime.js -function initData(context: any, data: any) { - if (!data || !('root' in data)) { - data = data ? createFrame(data) : {}; - data.root = context; - } - return data; -} - -// liftet from handlebars lib/handlebars/compiler/compiler.js -function transformLiteralToPath(node: { path: hbs.AST.PathExpression | hbs.AST.Literal }) { - const pathIsLiteral = 'parts' in node.path === false; - - if (pathIsLiteral) { - const literal = node.path; - // @ts-expect-error: Not all `hbs.AST.Literal` sub-types has an `original` property, but that's ok, in that case we just want `undefined` - const original = literal.original; - // Casting to string here to make false and 0 literal values play nicely with the rest - // of the system. - node.path = { - type: 'PathExpression', - data: false, - depth: 0, - parts: [original + ''], - original: original + '', - loc: literal.loc, - }; - } -} +export const compileFnName: 'compile' | 'compileAST' = allowUnsafeEval() ? 'compile' : 'compileAST'; -function allowUnsafeEval() { - try { - new Function(); - return true; - } catch (e) { - return false; - } -} +export type { + DecoratorFunction, + ExtendedCompileOptions, + ExtendedRuntimeOptions, +} from './src/types'; diff --git a/packages/kbn-handlebars/src/__jest__/test_bench.ts b/packages/kbn-handlebars/src/__jest__/test_bench.ts index c1407734f4fbe..9bccae90a86ae 100644 --- a/packages/kbn-handlebars/src/__jest__/test_bench.ts +++ b/packages/kbn-handlebars/src/__jest__/test_bench.ts @@ -3,12 +3,13 @@ * See `packages/kbn-handlebars/LICENSE` for more information. */ -import Handlebars, { - type DecoratorFunction, - type DecoratorsHash, - type ExtendedCompileOptions, - type ExtendedRuntimeOptions, -} from '../..'; +import Handlebars from '../..'; +import type { + DecoratorFunction, + DecoratorsHash, + ExtendedCompileOptions, + ExtendedRuntimeOptions, +} from '../types'; type CompileFns = 'compile' | 'compileAST'; const compileFns: CompileFns[] = ['compile', 'compileAST']; diff --git a/packages/kbn-handlebars/src/handlebars.ts b/packages/kbn-handlebars/src/handlebars.ts new file mode 100644 index 0000000000000..d2cb1c258d679 --- /dev/null +++ b/packages/kbn-handlebars/src/handlebars.ts @@ -0,0 +1,56 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +// The handlebars module uses `export =`, so we should technically use `import Handlebars = require('handlebars')`, but Babel will not allow this: +// https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import Handlebars from 'handlebars'; + +import type { DecoratorsHash, ExtendedCompileOptions, ExtendedRuntimeOptions } from './types'; +import { ElasticHandlebarsVisitor } from './visitor'; + +const originalCreate = Handlebars.create; + +export { Handlebars }; + +/** + * Creates an isolated Handlebars environment. + * + * Each environment has its own helpers. + * This is only necessary for use cases that demand distinct helpers. + * Most use cases can use the root Handlebars environment directly. + * + * @returns A sandboxed/scoped version of the @kbn/handlebars module + */ +Handlebars.create = function (): typeof Handlebars { + const SandboxedHandlebars = originalCreate.call(Handlebars) as typeof Handlebars; + // When creating new Handlebars environments, ensure the custom compileAST function is present in the new environment as well + SandboxedHandlebars.compileAST = Handlebars.compileAST; + return SandboxedHandlebars; +}; + +/** + * Compiles the given Handlbars template without the use of `eval`. + * + * @returns A render function with the same API as the return value from the regular Handlebars `compile` function. + */ +Handlebars.compileAST = function ( + input: string | hbs.AST.Program, + options?: ExtendedCompileOptions +) { + if (input == null || (typeof input !== 'string' && input.type !== 'Program')) { + throw new Handlebars.Exception( + `You must pass a string or Handlebars AST to Handlebars.compileAST. You passed ${input}` + ); + } + + // 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(this, input, options, helpers, partials, decorators); + return (context: any, runtimeOptions?: ExtendedRuntimeOptions) => + visitor.render(context, runtimeOptions); +}; diff --git a/packages/kbn-handlebars/src/symbols.ts b/packages/kbn-handlebars/src/symbols.ts new file mode 100644 index 0000000000000..85a8f2f311ab0 --- /dev/null +++ b/packages/kbn-handlebars/src/symbols.ts @@ -0,0 +1,8 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +export const kHelper = Symbol('helper'); +export const kAmbiguous = Symbol('ambiguous'); +export const kSimple = Symbol('simple'); diff --git a/packages/kbn-handlebars/src/types.ts b/packages/kbn-handlebars/src/types.ts new file mode 100644 index 0000000000000..fa11a0172e0dc --- /dev/null +++ b/packages/kbn-handlebars/src/types.ts @@ -0,0 +1,136 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { kHelper, kAmbiguous, kSimple } from './symbols'; + +/** + * A custom version of the Handlesbars module with an extra `compileAST` function and fixed typings. + */ +declare module 'handlebars' { + export function compileAST( + input: string | hbs.AST.Program, + options?: ExtendedCompileOptions + ): (context?: any, options?: ExtendedRuntimeOptions) => string; + + // -------------------------------------------------------- + // Override/Extend inherited types below that are incorrect + // -------------------------------------------------------- + + 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 { + name: string; + loc: { start: hbs.AST.SourceLocation['start']; end: hbs.AST.SourceLocation['end'] }; + lookupProperty: LookupProperty; + } + + export interface HelperDelegate { + // 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 +} + +export type NodeType = typeof kHelper | typeof kAmbiguous | typeof kSimple; + +type LookupProperty = (parent: { [name: string]: any }, propertyName: string) => T; + +export type ProcessableStatementNode = + | hbs.AST.MustacheStatement + | hbs.AST.PartialStatement + | hbs.AST.SubExpression; +export type ProcessableBlockStatementNode = hbs.AST.BlockStatement | hbs.AST.PartialBlockStatement; +export type ProcessableNode = ProcessableStatementNode | ProcessableBlockStatementNode; +export type ProcessableNodeWithPathParts = ProcessableNode & { path: hbs.AST.PathExpression }; +export type ProcessableNodeWithPathPartsOrLiteral = ProcessableNode & { + path: hbs.AST.PathExpression | hbs.AST.Literal; +}; + +export interface Helper { + fn?: Handlebars.HelperDelegate; + context: any[]; + params: any[]; + options: AmbiguousHelperOptions; +} + +export type NonBlockHelperOptions = Omit; +export type AmbiguousHelperOptions = Handlebars.HelperOptions | NonBlockHelperOptions; + +export interface DecoratorOptions extends Omit { + args?: any[]; +} + +/** + * Supported Handlebars compile options. + * + * This is a subset of all the compile options supported by the upstream + * Handlebars module. + */ +export type ExtendedCompileOptions = Pick< + CompileOptions, + | 'data' + | 'knownHelpers' + | 'knownHelpersOnly' + | 'noEscape' + | 'strict' + | 'assumeObjects' + | 'preventIndent' + | 'explicitPartialContext' +>; + +/** + * Supported Handlebars runtime options + * + * This is a subset of all the runtime options supported by the upstream + * Handlebars module. + */ +export type ExtendedRuntimeOptions = Pick< + RuntimeOptions, + 'data' | 'helpers' | 'partials' | 'decorators' | 'blockParams' +>; + +/** + * According to the [decorator docs]{@link https://github.com/handlebars-lang/handlebars.js/blob/4.x/docs/decorators-api.md}, + * a decorator will be called with a different set of arugments than what's actually happening in the upstream code. + * So here I assume that the docs are wrong and that the upstream code is correct. In reality, `context` is the last 4 + * documented arguments rolled into one object. + */ +export type DecoratorFunction = ( + prog: Handlebars.TemplateDelegate, + props: Record, + container: Container, + options: any +) => any; + +export interface HelpersHash { + [name: string]: Handlebars.HelperDelegate; +} + +export interface PartialsHash { + [name: string]: HandlebarsTemplateDelegate; +} + +export interface DecoratorsHash { + [name: string]: DecoratorFunction; +} + +export interface Container { + helpers: HelpersHash; + partials: PartialsHash; + decorators: DecoratorsHash; + strict: (obj: { [name: string]: any }, name: string, loc: hbs.AST.SourceLocation) => any; + lookupProperty: LookupProperty; + lambda: (current: any, context: any) => any; + data: (value: any, depth: number) => any; + hooks: { + helperMissing?: Handlebars.HelperDelegate; + blockHelperMissing?: Handlebars.HelperDelegate; + }; +} diff --git a/packages/kbn-handlebars/src/utils.ts b/packages/kbn-handlebars/src/utils.ts new file mode 100644 index 0000000000000..f55bd98abf0b3 --- /dev/null +++ b/packages/kbn-handlebars/src/utils.ts @@ -0,0 +1,69 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +// @ts-expect-error: Could not find a declaration file for module +import { createFrame } from 'handlebars/dist/cjs/handlebars/utils'; + +import type { AmbiguousHelperOptions, DecoratorOptions } from './types'; + +export function isBlock(node: hbs.AST.Node): node is hbs.AST.BlockStatement { + return 'program' in node || 'inverse' in node; +} + +export function isDecorator( + node: hbs.AST.Node +): node is hbs.AST.Decorator | hbs.AST.DecoratorBlock { + return node.type === 'Decorator' || node.type === 'DecoratorBlock'; +} + +export function toDecoratorOptions(options: AmbiguousHelperOptions) { + // There's really no tests/documentation on this, but to match the upstream codebase we'll remove `lookupProperty` from the decorator context + delete (options as any).lookupProperty; + + return options as DecoratorOptions; +} + +export function noop() { + return ''; +} + +// liftet from handlebars lib/handlebars/runtime.js +export function initData(context: any, data: any) { + if (!data || !('root' in data)) { + data = data ? createFrame(data) : {}; + data.root = context; + } + return data; +} + +// liftet from handlebars lib/handlebars/compiler/compiler.js +export function transformLiteralToPath(node: { path: hbs.AST.PathExpression | hbs.AST.Literal }) { + const pathIsLiteral = 'parts' in node.path === false; + + if (pathIsLiteral) { + const literal = node.path; + // @ts-expect-error: Not all `hbs.AST.Literal` sub-types has an `original` property, but that's ok, in that case we just want `undefined` + const original = literal.original; + // Casting to string here to make false and 0 literal values play nicely with the rest + // of the system. + node.path = { + type: 'PathExpression', + data: false, + depth: 0, + parts: [original + ''], + original: original + '', + loc: literal.loc, + }; + } +} + +export function allowUnsafeEval() { + try { + new Function(); + return true; + } catch (e) { + return false; + } +} diff --git a/packages/kbn-handlebars/src/visitor.ts b/packages/kbn-handlebars/src/visitor.ts new file mode 100644 index 0000000000000..397f896496329 --- /dev/null +++ b/packages/kbn-handlebars/src/visitor.ts @@ -0,0 +1,791 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from 'handlebars'; +import { + createProtoAccessControl, + resultIsAllowed, + // @ts-expect-error: Could not find a declaration file for module +} from 'handlebars/dist/cjs/handlebars/internal/proto-access'; +// @ts-expect-error: Could not find a declaration file for module +import AST from 'handlebars/dist/cjs/handlebars/compiler/ast'; +// @ts-expect-error: Could not find a declaration file for module +import { indexOf, createFrame } from 'handlebars/dist/cjs/handlebars/utils'; +// @ts-expect-error: Could not find a declaration file for module +import { moveHelperToHooks } from 'handlebars/dist/cjs/handlebars/helpers'; + +import type { + AmbiguousHelperOptions, + Container, + DecoratorFunction, + DecoratorsHash, + ExtendedCompileOptions, + ExtendedRuntimeOptions, + Helper, + HelpersHash, + NodeType, + NonBlockHelperOptions, + PartialsHash, + ProcessableBlockStatementNode, + ProcessableNode, + ProcessableNodeWithPathParts, + ProcessableNodeWithPathPartsOrLiteral, + ProcessableStatementNode, +} from './types'; +import { kAmbiguous, kHelper, kSimple } from './symbols'; +import { + initData, + isBlock, + isDecorator, + noop, + toDecoratorOptions, + transformLiteralToPath, +} from './utils'; + +export 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[][] = []; + private ast?: hbs.AST.Program; + private container: Container; + private defaultHelperOptions: Pick; + private processedRootDecorators = false; // Root decorators should not have access to input arguments. This flag helps us detect them. + 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 { + this.template = input as string; + } + + this.compileOptions = Object.assign( + { + data: true, + }, + options + ); + + this.compileOptions.knownHelpers = Object.assign( + Object.create(null), + { + helperMissing: true, + blockHelperMissing: true, + each: true, + if: true, + unless: true, + with: true, + log: true, + lookup: true, + }, + this.compileOptions.knownHelpers + ); + + 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)) { + throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, { + loc, + } as hbs.AST.Node); + } + return container.lookupProperty(obj, name); + }, + // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) + lookupProperty(parent, propertyName) { + const result = parent[propertyName]; + if (result == null) { + return result; + } + if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { + return result; + } + + if (resultIsAllowed(result, protoAccessControl, propertyName)) { + return result; + } + return undefined; + }, + // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) + lambda(current, context) { + return typeof current === 'function' ? current.call(context) : current; + }, + data(value: any, depth: number) { + while (value && depth--) { + value = value._parent; + } + return value; + }, + hooks: {}, + }); + + this.defaultHelperOptions = { + lookupProperty: container.lookupProperty, + }; + } + + render(context: any, options: ExtendedRuntimeOptions = {}): string { + this.contexts = [context]; + 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 + ); + this.container.hooks = {}; + this.processedRootDecorators = false; + this.processedDecoratorsForProgram.clear(); + + if (this.compileOptions.data) { + this.runtimeOptions.data = initData(context, this.runtimeOptions.data); + } + + const keepHelperInHelpers = false; + moveHelperToHooks(this.container, 'helperMissing', keepHelperInHelpers); + moveHelperToHooks(this.container, 'blockHelperMissing', keepHelperInHelpers); + + if (!this.ast) { + this.ast = Handlebars.parse(this.template!); + } + + // The `defaultMain` function contains the default behavior: + // + // Generate a "program" function based on the root `Program` in the AST and + // call it. This will start the processing of all the child nodes in the + // AST. + const defaultMain: Handlebars.TemplateDelegate = (_context) => { + const prog = this.generateProgramFunction(this.ast!); + return prog(_context, this.runtimeOptions); + }; + + // Run any decorators that might exist on the root: + // + // The `defaultMain` function is passed in, and if there are no root + // decorators, or if the decorators chooses to do so, the same function is + // returned from `processDecorators` and the default behavior is retained. + // + // Alternatively any of the root decorators might call the `defaultMain` + // function themselves, process its return value, and return a completely + // different `main` function. + const main = this.processDecorators(this.ast, defaultMain); + this.processedRootDecorators = true; + + // Call the `main` function and add the result to the final output. + const result = main(this.context, options); + + if (main === defaultMain) { + this.output.push(result); + return this.output.join(''); + } else { + // We normally expect the return value of `main` to be a string. However, + // if a decorator is used to override the `defaultMain` function, the + // return value can be any type. To match the upstream handlebars project + // behavior, we want the result of rendering the template to be the + // literal value returned by the decorator. + // + // Since the output array in this case always will be empty, we just + // return that single value instead of attempting to join all the array + // elements as strings. + return result; + } + } + + // ********************************************** // + // *** Visitor AST Traversal Functions *** // + // ********************************************** // + + Program(program: hbs.AST.Program) { + this.blockParamNames.unshift(program.blockParams); + super.Program(program); + this.blockParamNames.shift(); + } + + MustacheStatement(mustache: hbs.AST.MustacheStatement) { + this.processStatementOrExpression(mustache); + } + + BlockStatement(block: hbs.AST.BlockStatement) { + 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. + DecoratorBlock(decorator: hbs.AST.DecoratorBlock) {} + + // 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. + Decorator(decorator: hbs.AST.Decorator) {} + + SubExpression(sexpr: hbs.AST.SubExpression) { + this.processStatementOrExpression(sexpr); + } + + PathExpression(path: hbs.AST.PathExpression) { + const blockParamId = + !path.depth && !AST.helpers.scopedId(path) && this.blockParamIndex(path.parts[0]); + + let result; + if (blockParamId) { + result = this.lookupBlockParam(blockParamId, path); + } else if (path.data) { + result = this.lookupData(this.runtimeOptions!.data, path); + } else { + result = this.resolvePath(this.contexts[path.depth], path); + } + + this.output.push(result); + } + + ContentStatement(content: hbs.AST.ContentStatement) { + this.output.push(content.value); + } + + StringLiteral(string: hbs.AST.StringLiteral) { + this.output.push(string.value); + } + + NumberLiteral(number: hbs.AST.NumberLiteral) { + this.output.push(number.value); + } + + BooleanLiteral(bool: hbs.AST.BooleanLiteral) { + this.output.push(bool.value); + } + + UndefinedLiteral() { + this.output.push(undefined); + } + + NullLiteral() { + this.output.push(null); + } + + // ********************************************** // + // *** Visitor AST Helper Functions *** // + // ********************************************** // + + /** + * Special code for decorators, since they have to be executed ahead of time (before the wrapping program). + * So we have to look into the program AST body and see if it contains any decorators that we have to process + * before we can finish processing of the wrapping program. + */ + private processDecorators(program: hbs.AST.Program, prog: Handlebars.TemplateDelegate) { + if (!this.processedDecoratorsForProgram.has(program)) { + this.processedDecoratorsForProgram.add(program); + const props = {}; + for (const node of program.body) { + if (isDecorator(node)) { + prog = this.processDecorator(node, prog, props); + } + } + } + + return prog; + } + + private processDecorator( + decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator, + prog: Handlebars.TemplateDelegate, + props: Record + ) { + const options = this.setupDecoratorOptions(decorator); + + const result = this.container.lookupProperty( + this.container.decorators, + options.name + )(prog, props, this.container, options); + + return Object.assign(result || prog, props); + } + + private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) { + // Calling `transformLiteralToPath` has side-effects! + // It converts a node from type `ProcessableNodeWithPathPartsOrLiteral` to `ProcessableNodeWithPathParts` + transformLiteralToPath(node); + + switch (this.classifyNode(node as ProcessableNodeWithPathParts)) { + case kSimple: + this.processSimpleNode(node as ProcessableNodeWithPathParts); + break; + case kHelper: + this.processHelperNode(node as ProcessableNodeWithPathParts); + break; + case kAmbiguous: + this.processAmbiguousNode(node as ProcessableNodeWithPathParts); + break; + } + } + + // Liftet from lib/handlebars/compiler/compiler.js (original name: classifySexpr) + private classifyNode(node: { path: hbs.AST.PathExpression }): NodeType { + const isSimple = AST.helpers.simpleId(node.path); + const isBlockParam = isSimple && !!this.blockParamIndex(node.path.parts[0]); + + // a mustache is an eligible helper if: + // * its id is simple (a single part, not `this` or `..`) + let isHelper = !isBlockParam && AST.helpers.helperExpression(node); + + // if a mustache is an eligible helper but not a definite + // helper, it is ambiguous, and will be resolved in a later + // pass or at runtime. + let isEligible = !isBlockParam && (isHelper || isSimple); + + // if ambiguous, we can possibly resolve the ambiguity now + // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc. + if (isEligible && !isHelper) { + const name = node.path.parts[0]; + const options = this.compileOptions; + if (options.knownHelpers && options.knownHelpers[name]) { + isHelper = true; + } else if (options.knownHelpersOnly) { + isEligible = false; + } + } + + if (isHelper) { + return kHelper; + } else if (isEligible) { + return kAmbiguous; + } else { + return kSimple; + } + } + + // Liftet from lib/handlebars/compiler/compiler.js + private blockParamIndex(name: string): [number, any] | undefined { + for (let depth = 0, len = this.blockParamNames.length; depth < len; depth++) { + const blockParams = this.blockParamNames[depth]; + const param = blockParams && indexOf(blockParams, name); + if (blockParams && param >= 0) { + return [depth, param]; + } + } + } + + // Looks up the value of `parts` on the given block param and pushes + // it onto the stack. + private lookupBlockParam(blockParamId: [number, any], path: hbs.AST.PathExpression) { + const value = this.blockParamValues[blockParamId[0]][blockParamId[1]]; + return this.resolvePath(value, path, 1); + } + + // Push the data lookup operator + private lookupData(data: any, path: hbs.AST.PathExpression) { + if (path.depth) { + data = this.container.data(data, path.depth); + } + + return this.resolvePath(data, path); + } + + private processSimpleNode(node: ProcessableNodeWithPathParts) { + const path = node.path; + // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars + path.strict = true; + const result = this.resolveNodes(path)[0]; + const lambdaResult = this.container.lambda(result, this.context); + + if (isBlock(node)) { + this.blockValue(node, lambdaResult); + } else { + this.output.push(lambdaResult); + } + } + + // The purpose of this opcode is to take a block of the form + // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and + // replace it on the stack with the result of properly + // invoking blockHelperMissing. + private blockValue(node: hbs.AST.BlockStatement, value: any) { + const name = node.path.original; + const options = this.setupParams(node, name); + + const result = this.container.hooks.blockHelperMissing!.call(this.context, value, options); + + this.output.push(result); + } + + private processHelperNode(node: ProcessableNodeWithPathParts) { + const path = node.path; + const name = path.parts[0]; + + if (this.compileOptions.knownHelpers && this.compileOptions.knownHelpers[name]) { + this.invokeKnownHelper(node); + } else if (this.compileOptions.knownHelpersOnly) { + throw new Handlebars.Exception( + 'You specified knownHelpersOnly, but used the unknown helper ' + name, + node + ); + } else { + this.invokeHelper(node); + } + } + + // This operation is used when the helper is known to exist, + // so a `helperMissing` fallback is not required. + private invokeKnownHelper(node: ProcessableNodeWithPathParts) { + const name = node.path.parts[0]; + const helper = this.setupHelper(node, name); + // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards + const result = helper.fn!.call(helper.context, ...helper.params, helper.options); + this.output.push(result); + } + + // Pops off the helper's parameters, invokes the helper, + // and pushes the helper's return value onto the stack. + // + // If the helper is not found, `helperMissing` is called. + private invokeHelper(node: ProcessableNodeWithPathParts) { + const path = node.path; + const name = path.original; + const isSimple = AST.helpers.simpleId(path); + const helper = this.setupHelper(node, name); + + const loc = isSimple && helper.fn ? node.loc : path.loc; + helper.fn = (isSimple && helper.fn) || this.resolveNodes(path)[0]; + + if (!helper.fn) { + if (this.compileOptions.strict) { + helper.fn = this.container.strict(helper.context, name, loc); + } else { + helper.fn = this.container.hooks.helperMissing; + } + } + + // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards + const result = helper.fn!.call(helper.context, ...helper.params, helper.options); + + 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.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); + let { fn: helperFn } = helper; + + const loc = helperFn ? node.loc : node.path.loc; + helperFn = helperFn ?? this.resolveNodes(node.path)[0]; + + if (helperFn === undefined) { + if (this.compileOptions.strict) { + helperFn = this.container.strict(helper.context, name, loc); + } else { + helperFn = + helper.context != null + ? this.container.lookupProperty(helper.context, name) + : helper.context; + if (helperFn == null) helperFn = this.container.hooks.helperMissing; + } + } + + const helperResult = + typeof helperFn === 'function' + ? helperFn.call(helper.context, ...helper.params, helper.options) + : helperFn; + + if (isBlock(node)) { + const result = helper.fn + ? helperResult + : this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options); + if (result != null) { + this.output.push(result); + } + } else { + if ( + (node as hbs.AST.MustacheStatement).escaped === false || + this.compileOptions.noEscape === true || + typeof helperResult !== 'string' + ) { + this.output.push(helperResult); + } else { + this.output.push(Handlebars.escapeExpression(helperResult)); + } + } + } + + private setupHelper(node: ProcessableNode, helperName: string): Helper { + return { + fn: this.container.lookupProperty(this.container.helpers, helperName), + context: this.context, + params: this.resolveNodes(node.params), + options: this.setupParams(node, helperName), + }; + } + + private setupDecoratorOptions(decorator: hbs.AST.Decorator | hbs.AST.DecoratorBlock) { + // TypeScript: The types indicate that `decorator.path` technically can be an `hbs.AST.Literal`. However, the upstream codebase always treats it as an `hbs.AST.PathExpression`, so we do too. + const name = (decorator.path as hbs.AST.PathExpression).original; + const options = toDecoratorOptions(this.setupParams(decorator, name)); + + if (decorator.params.length > 0) { + if (!this.processedRootDecorators) { + // When processing the root decorators, temporarily remove the root context so it's not accessible to the decorator + const context = this.contexts.shift(); + options.args = this.resolveNodes(decorator.params); + this.contexts.unshift(context); + } else { + options.args = this.resolveNodes(decorator.params); + } + } else { + options.args = []; + } + + return options; + } + + private setupParams(node: ProcessableBlockStatementNode, name: string): Handlebars.HelperOptions; + private setupParams(node: ProcessableStatementNode, name: string): NonBlockHelperOptions; + private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions; + private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions { + const options = { + name, + hash: this.getHash(node), + data: this.runtimeOptions!.data, + loc: { start: node.loc.start, end: node.loc.end }, + ...this.defaultHelperOptions, + }; + + if (isBlock(node)) { + (options as Handlebars.HelperOptions).fn = node.program + ? this.processDecorators(node.program, this.generateProgramFunction(node.program)) + : noop; + (options as Handlebars.HelperOptions).inverse = node.inverse + ? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse)) + : noop; + } + + return options; + } + + private generateProgramFunction(program: hbs.AST.Program) { + if (!program) return noop; + + const prog: Handlebars.TemplateDelegate = ( + nextContext: any, + runtimeOptions: ExtendedRuntimeOptions = {} + ) => { + runtimeOptions = Object.assign({}, runtimeOptions); + + // inherit data in blockParams from parent program + runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data; + if (runtimeOptions.blockParams) { + runtimeOptions.blockParams = runtimeOptions.blockParams.concat( + this.runtimeOptions!.blockParams + ); + } + + // inherit partials from parent program + runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials; + + // stash parent program data + const tmpRuntimeOptions = this.runtimeOptions; + this.runtimeOptions = runtimeOptions; + const shiftContext = nextContext !== this.context; + if (shiftContext) this.contexts.unshift(nextContext); + this.blockParamValues.unshift(runtimeOptions.blockParams || []); + + // execute child program + const result = this.resolveNodes(program).join(''); + + // unstash parent program data + this.blockParamValues.shift(); + if (shiftContext) this.contexts.shift(); + this.runtimeOptions = tmpRuntimeOptions; + + // return result of child program + return result; + }; + + prog.blockParams = program.blockParams?.length ?? 0; + return prog; + } + + private getHash(statement: { hash?: hbs.AST.Hash }) { + const result: { [key: string]: any } = {}; + if (!statement.hash) return result; + for (const { key, value } of statement.hash.pairs) { + result[key] = this.resolveNodes(value)[0]; + } + return result; + } + + private resolvePath(obj: any, path: hbs.AST.PathExpression, index = 0) { + if (this.compileOptions.strict || this.compileOptions.assumeObjects) { + return this.strictLookup(obj, path); + } + + for (; index < path.parts.length; index++) { + if (obj == null) return; + obj = this.container.lookupProperty(obj, path.parts[index]); + } + + return obj; + } + + private strictLookup(obj: any, path: hbs.AST.PathExpression) { + // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars + const requireTerminal = this.compileOptions.strict && path.strict; + const len = path.parts.length - (requireTerminal ? 1 : 0); + + for (let i = 0; i < len; i++) { + obj = this.container.lookupProperty(obj, path.parts[i]); + } + + if (requireTerminal) { + return this.container.strict(obj, path.parts[len], path.loc); + } else { + return obj; + } + } + + private resolveNodes(nodes: hbs.AST.Node | hbs.AST.Node[]): any[] { + const currentOutput = this.output; + this.output = []; + + if (Array.isArray(nodes)) { + this.acceptArray(nodes); + } else { + this.accept(nodes); + } + + const result = this.output; + + this.output = currentOutput; + + return result; + } + + private get context() { + return this.contexts[0]; + } +} diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/replace_vars.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/replace_vars.ts index d3a63f9f2421d..8d134e86c78db 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/replace_vars.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/replace_vars.ts @@ -7,7 +7,7 @@ */ import { encode, RisonValue } from 'rison-node'; -import Handlebars, { ExtendedCompileOptions, compileFnName } from '@kbn/handlebars'; +import Handlebars, { type ExtendedCompileOptions, compileFnName } from '@kbn/handlebars'; import { i18n } from '@kbn/i18n'; import { emptyLabel } from '../../../../common/empty_label';