handlebars
Version:
Handlebars provides the power necessary to let you build semantic templates effectively with no frustration
736 lines (630 loc) • 20.5 kB
JavaScript
/* eslint-disable new-cap */
import Exception from '../exception';
import { isArray, indexOf, extend } from '../utils';
import AST from './ast';
import { NODE_TYPES } from './node-types';
const slice = [].slice;
export function Compiler() {}
// the foundHelper register will disambiguate helper lookup from finding a
// function in a context. This is necessary for mustache compatibility, which
// requires that context functions in blocks are evaluated by blockHelperMissing,
// and then proceed as if the resulting value was provided to blockHelperMissing.
Compiler.prototype = {
compiler: Compiler,
equals: function(other) {
let len = this.opcodes.length;
if (other.opcodes.length !== len) {
return false;
}
for (let i = 0; i < len; i++) {
let opcode = this.opcodes[i],
otherOpcode = other.opcodes[i];
if (
opcode.opcode !== otherOpcode.opcode ||
!argEquals(opcode.args, otherOpcode.args)
) {
return false;
}
}
// We know that length is the same between the two arrays because they are directly tied
// to the opcode behavior above.
len = this.children.length;
for (let i = 0; i < len; i++) {
if (!this.children[i].equals(other.children[i])) {
return false;
}
}
return true;
},
guid: 0,
compile: function(program, options) {
this.sourceNode = [];
this.opcodes = [];
this.children = [];
this.options = options;
this.stringParams = options.stringParams;
this.trackIds = options.trackIds;
options.blockParams = options.blockParams || [];
options.knownHelpers = extend(
Object.create(null),
{
helperMissing: true,
blockHelperMissing: true,
each: true,
if: true,
unless: true,
with: true,
log: true,
lookup: true
},
options.knownHelpers
);
return this.accept(program);
},
compileProgram: function(program) {
let childCompiler = new this.compiler(), // eslint-disable-line new-cap
result = childCompiler.compile(program, this.options),
guid = this.guid++;
this.usePartial = this.usePartial || result.usePartial;
this.children[guid] = result;
this.useDepths = this.useDepths || result.useDepths;
return guid;
},
accept: function(node) {
// Dispatching on an unknown type could call an arbitrary Compiler method.
if (indexOf(NODE_TYPES, node.type) === -1 || !this[node.type]) {
throw new Exception('Unknown type: ' + node.type, node);
}
this.sourceNode.unshift(node);
let ret = this[node.type](node);
this.sourceNode.shift();
return ret;
},
Program: function(program) {
validateBlockParams(program);
this.options.blockParams.unshift(program.blockParams);
let body = program.body,
bodyLength = body.length;
for (let i = 0; i < bodyLength; i++) {
this.accept(body[i]);
}
this.options.blockParams.shift();
this.isSimple = bodyLength === 1;
this.blockParams = program.blockParams ? program.blockParams.length : 0;
return this;
},
BlockStatement: function(block) {
transformLiteralToPath(block);
let program = block.program,
inverse = block.inverse;
program = program && this.compileProgram(program);
inverse = inverse && this.compileProgram(inverse);
let type = this.classifySexpr(block);
if (type === 'helper') {
this.helperSexpr(block, program, inverse);
} else if (type === 'simple') {
this.simpleSexpr(block);
// now that the simple mustache is resolved, we need to
// evaluate it by executing `blockHelperMissing`
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
this.opcode('emptyHash');
this.opcode('blockValue', block.path.original);
} else {
this.ambiguousSexpr(block, program, inverse);
// now that the simple mustache is resolved, we need to
// evaluate it by executing `blockHelperMissing`
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
this.opcode('emptyHash');
this.opcode('ambiguousBlockValue');
}
this.opcode('append');
},
DecoratorBlock(decorator) {
let program = decorator.program && this.compileProgram(decorator.program);
let params = this.setupFullMustacheParams(decorator, program, undefined),
path = decorator.path;
this.useDecorators = true;
this.opcode('registerDecorator', params.length, path.original);
},
PartialStatement: function(partial) {
this.usePartial = true;
let program = partial.program;
if (program) {
program = this.compileProgram(partial.program);
}
let params = partial.params;
if (params.length > 1) {
throw new Exception(
'Unsupported number of partial arguments: ' + params.length,
partial
);
} else if (!params.length) {
if (this.options.explicitPartialContext) {
this.opcode('pushLiteral', 'undefined');
} else {
params.push({ type: 'PathExpression', parts: [], depth: 0 });
}
}
let partialName = partial.name.original,
isDynamic = partial.name.type === 'SubExpression';
if (isDynamic) {
this.accept(partial.name);
}
this.setupFullMustacheParams(partial, program, undefined, true);
let indent = partial.indent || '';
if (this.options.preventIndent && indent) {
this.opcode('appendContent', indent);
indent = '';
}
this.opcode('invokePartial', isDynamic, partialName, indent);
this.opcode('append');
},
PartialBlockStatement: function(partialBlock) {
this.PartialStatement(partialBlock);
},
MustacheStatement: function(mustache) {
this.SubExpression(mustache);
if (mustache.escaped && !this.options.noEscape) {
this.opcode('appendEscaped');
} else {
this.opcode('append');
}
},
Decorator(decorator) {
this.DecoratorBlock(decorator);
},
ContentStatement: function(content) {
if (content.value != null && typeof content.value !== 'string') {
throw invalidAst(content, 'value must be a string');
}
if (content.value) {
this.opcode('appendContent', content.value);
}
},
CommentStatement: function() {},
SubExpression: function(sexpr) {
transformLiteralToPath(sexpr);
let type = this.classifySexpr(sexpr);
if (type === 'simple') {
this.simpleSexpr(sexpr);
} else if (type === 'helper') {
this.helperSexpr(sexpr);
} else {
this.ambiguousSexpr(sexpr);
}
},
ambiguousSexpr: function(sexpr, program, inverse) {
let path = sexpr.path,
name = path.parts[0],
isBlock = program != null || inverse != null;
this.opcode('getContext', validateDepth(path));
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
path.strict = true;
this.accept(path);
this.opcode('invokeAmbiguous', name, isBlock);
},
simpleSexpr: function(sexpr) {
let path = sexpr.path;
path.strict = true;
this.accept(path);
this.opcode('resolvePossibleLambda');
},
helperSexpr: function(sexpr, program, inverse) {
let params = this.setupFullMustacheParams(sexpr, program, inverse),
path = sexpr.path,
name = path.parts[0];
if (this.options.knownHelpers[name]) {
this.opcode('invokeKnownHelper', params.length, name);
} else if (this.options.knownHelpersOnly) {
throw new Exception(
'You specified knownHelpersOnly, but used the unknown helper ' + name,
sexpr
);
} else {
path.strict = true;
path.falsy = true;
this.accept(path);
this.opcode(
'invokeHelper',
params.length,
path.original,
AST.helpers.simpleId(path)
);
}
},
PathExpression: function(path) {
// Validate AST values at the compiler boundary: javascript-compiler.js
// trusts all opcode arguments to be safe.
const depth = validateDepth(path);
const parts = validateParts(path);
this.addDepth(depth);
this.opcode('getContext', depth);
let name = parts[0],
scoped = AST.helpers.scopedId(path),
blockParamId = !depth && !scoped && this.blockParamIndex(name);
if (blockParamId) {
this.opcode('lookupBlockParam', blockParamId, parts);
} else if (!name) {
// Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
this.opcode('pushContext');
} else if (path.data) {
this.options.data = true;
this.opcode('lookupData', depth, parts, path.strict);
} else {
this.opcode('lookupOnContext', parts, path.falsy, path.strict, scoped);
}
},
StringLiteral: function(string) {
validateLiteral(string);
this.opcode('pushString', string.value);
},
NumberLiteral: function(number) {
validateLiteral(number);
this.opcode('pushLiteral', number.value);
},
BooleanLiteral: function(bool) {
validateLiteral(bool);
this.opcode('pushLiteral', bool.value);
},
UndefinedLiteral: function() {
this.opcode('pushLiteral', 'undefined');
},
NullLiteral: function() {
this.opcode('pushLiteral', 'null');
},
Hash: function(hash) {
let pairs = hash.pairs,
i = 0,
l;
if (!isArray(pairs)) {
throw invalidAst(hash, 'pairs must be an array');
}
l = pairs.length;
for (let j = 0; j < l; j++) {
if (!pairs[j] || typeof pairs[j].key !== 'string') {
throw invalidAst(hash, 'pair keys must be strings');
}
}
this.opcode('pushHash');
for (; i < l; i++) {
this.pushParam(pairs[i].value);
}
while (i--) {
this.opcode('assignToHash', pairs[i].key);
}
this.opcode('popHash');
},
// HELPERS
opcode: function(name) {
this.opcodes.push({
opcode: name,
args: slice.call(arguments, 1),
loc: this.sourceNode[0].loc
});
},
addDepth: function(depth) {
if (!depth) {
return;
}
this.useDepths = true;
},
classifySexpr: function(sexpr) {
// The first part is used as a helper name below, e.g. for known helpers,
// before the PathExpression handler gets to validate the parts.
validateParts(sexpr.path);
let isSimple = AST.helpers.simpleId(sexpr.path);
let isBlockParam = isSimple && !!this.blockParamIndex(sexpr.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(sexpr);
// 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) {
let name = sexpr.path.parts[0],
options = this.options;
if (options.knownHelpers[name]) {
isHelper = true;
} else if (options.knownHelpersOnly) {
isEligible = false;
}
}
if (isHelper) {
return 'helper';
} else if (isEligible) {
return 'ambiguous';
} else {
return 'simple';
}
},
pushParams: function(params) {
for (let i = 0, l = params.length; i < l; i++) {
this.pushParam(params[i]);
}
},
pushParam: function(val) {
// Literal params are not visited by their node handlers in stringParams
// mode, so their values must be validated here as well.
validateLiteral(val);
let value = val.value != null ? val.value : val.original || '';
let depth = validateDepth(val);
if (this.stringParams) {
// pushStringParam emits non-string values as raw JavaScript literals.
// Literal values are validated above, but `original` is not, so
// anything that is not a number or boolean is stringified.
if (typeof value !== 'number' && typeof value !== 'boolean') {
value = String(value);
}
if (value.replace) {
value = value.replace(/^(\.?\.\/)*/g, '').replace(/\//g, '.');
}
if (depth) {
this.addDepth(depth);
}
this.opcode('getContext', depth);
this.opcode('pushStringParam', value, val.type);
if (val.type === 'SubExpression') {
// SubExpressions get evaluated and passed in
// in string params mode.
this.accept(val);
}
} else {
if (this.trackIds) {
let blockParamIndex;
if (val.parts && !AST.helpers.scopedId(val) && !depth) {
// The parts are read here before the PathExpression handler,
// invoked by accept() below, gets to validate them.
blockParamIndex = this.blockParamIndex(validateParts(val)[0]);
}
if (blockParamIndex) {
let blockParamChild = val.parts.slice(1).join('.');
this.opcode('pushId', 'BlockParam', blockParamIndex, blockParamChild);
} else {
value = val.original || value;
if (value.replace) {
value = value
.replace(/^this(?:\.|$)/, '')
.replace(/^\.\//, '')
.replace(/^\.$/, '');
}
this.opcode('pushId', val.type, value);
}
}
this.accept(val);
}
},
setupFullMustacheParams: function(sexpr, program, inverse, omitEmpty) {
let params = sexpr.params;
this.pushParams(params);
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
if (sexpr.hash) {
this.accept(sexpr.hash);
} else {
this.opcode('emptyHash', omitEmpty);
}
return params;
},
blockParamIndex: function(name) {
for (
let depth = 0, len = this.options.blockParams.length;
depth < len;
depth++
) {
let blockParams = this.options.blockParams[depth],
param = blockParams && indexOf(blockParams, name);
if (blockParams && param >= 0) {
return [depth, param];
}
}
}
};
export function precompile(input, options, env) {
if (
input == null ||
(typeof input !== 'string' && input.type !== 'Program')
) {
throw new Exception(
'You must pass a string or Handlebars AST to Handlebars.precompile. You passed ' +
input
);
}
options = options || {};
if (!('data' in options)) {
options.data = true;
}
if (options.compat) {
options.useDepths = true;
}
let ast = env.parse(input, options),
environment = new env.Compiler().compile(ast, options);
return new env.JavaScriptCompiler().compile(environment, options);
}
export function compile(input, options = {}, env) {
if (
input == null ||
(typeof input !== 'string' && input.type !== 'Program')
) {
throw new Exception(
'You must pass a string or Handlebars AST to Handlebars.compile. You passed ' +
input
);
}
options = extend({}, options);
if (!('data' in options)) {
options.data = true;
}
if (options.compat) {
options.useDepths = true;
}
let compiled;
function compileInput() {
let ast = env.parse(input, options),
environment = new env.Compiler().compile(ast, options),
templateSpec = new env.JavaScriptCompiler().compile(
environment,
options,
undefined,
true
);
return env.template(templateSpec);
}
// Template is only compiled on first use and cached after that point.
function ret(context, execOptions) {
if (!compiled) {
compiled = compileInput();
}
return compiled.call(this, context, execOptions);
}
ret._setup = function(setupOptions) {
if (!compiled) {
compiled = compileInput();
}
return compiled._setup(setupOptions);
};
ret._child = function(i, data, blockParams, depths) {
if (!compiled) {
compiled = compileInput();
}
return compiled._child(i, data, blockParams, depths);
};
return ret;
}
/**
* Create the exception thrown for an AST node that fails validation.
*
* @param {Object} node the offending AST node; its `type` is included in the
* message and its `loc`, if present, in the error location
* @param {string} message what is wrong with the node
* @returns {Exception} the exception to throw
*/
function invalidAst(node, message) {
return new Exception('Invalid AST: ' + node.type + ' ' + message, node);
}
/**
* Validate the `depth` of a path or param node.
*
* A missing depth is treated as 0, because ASTs built by hand or by other
* tools commonly omit it. Any depth that is present must be a non-negative
* integer, since it is written into the generated code.
*
* @param {Object} node the AST node whose `depth` is validated
* @returns {number} the validated depth, or 0 if the node has none
* @throws {Exception} if the depth is present but not a non-negative integer
*/
function validateDepth(node) {
let depth = node.depth;
if (depth == null) {
return 0;
}
if (
typeof depth !== 'number' ||
!isFinite(depth) ||
Math.floor(depth) !== depth ||
depth < 0
) {
throw invalidAst(node, 'depth must be a non-negative integer');
}
return depth;
}
/**
* Validate the `value` of a StringLiteral, NumberLiteral or BooleanLiteral.
*
* Literal values are written into the generated code, so they must have
* exactly the type their node promises. Nodes of any other type pass through
* unchanged.
*
* Numbers need not be finite: the parser produces Infinity for very long
* number literals, and Infinity and NaN are emitted as the read-only globals
* of the same name.
*
* @param {Object} node the AST node to validate
* @throws {Exception} if the value does not match the literal type
*/
function validateLiteral(node) {
let value = node.value;
if (node.type === 'StringLiteral' && typeof value !== 'string') {
throw invalidAst(node, 'value must be a string');
} else if (node.type === 'NumberLiteral' && typeof value !== 'number') {
throw invalidAst(node, 'value must be a number');
} else if (node.type === 'BooleanLiteral' && typeof value !== 'boolean') {
throw invalidAst(node, 'value must be a boolean');
}
}
/**
* Validate that a node property is an array containing only strings.
*
* @param {Object} node the AST node the property belongs to, for the error
* @param {*} value the property value to validate
* @param {string} name the property name, used in the error message
* @returns {string[]} the validated array
* @throws {Exception} if the value is not an array or contains non-strings
*/
function validateStringArray(node, value, name) {
if (!isArray(value)) {
throw invalidAst(node, name + ' must be an array');
}
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== 'string') {
throw invalidAst(node, name + ' must only contain strings');
}
}
return value;
}
/**
* Validate the `parts` of a PathExpression node.
*
* @param {Object} path the PathExpression node
* @returns {string[]} the validated parts
* @throws {Exception} if `parts` is not an array of strings
*/
function validateParts(path) {
return validateStringArray(path, path.parts, 'parts');
}
/**
* Validate the optional `blockParams` of a Program node. Its length is
* written into the generated code, so it must be an array of strings when
* present.
*
* @param {Object} program the Program node
* @throws {Exception} if `blockParams` is present and not an array of strings
*/
function validateBlockParams(program) {
if (program.blockParams != null) {
validateStringArray(program, program.blockParams, 'blockParams');
}
}
function argEquals(a, b) {
if (a === b) {
return true;
}
if (isArray(a) && isArray(b) && a.length === b.length) {
for (let i = 0; i < a.length; i++) {
if (!argEquals(a[i], b[i])) {
return false;
}
}
return true;
}
}
function transformLiteralToPath(sexpr) {
if (!sexpr.path.parts) {
let literal = sexpr.path;
// Casting to string here to make false and 0 literal values play nicely with the rest
// of the system.
sexpr.path = {
type: 'PathExpression',
data: false,
depth: 0,
parts: [literal.original + ''],
original: literal.original + '',
loc: literal.loc
};
}
}