/**
* Copyright (c) 2012-present, The Dojo Foundation .
* Based on Underscore.js 1.8.3
* Copyright 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
* Available under MIT license
* Copyright (c) 2015-present, Jon Schlinkert.
*/
'use strict';
const assign = require('assign-deep');
const utils = require('./utils');
/**
* Create an instance of `Engine` with the given options.
*
* ```js
* const Engine = require('engine');
* const engine = new Engine();
* ```
* @param {Object} `options`
* @api public
*/
class Engine {
constructor(options = {}) {
this.options = options;
this.imports = this.options.imports || {};
this.options.variable = '';
this.settings = {};
this.counter = 0;
this.cache = {};
// regex
this.options.escape = this.options.escape || utils.reEscape;
this.options.evaluate = this.options.evaluate || utils.reEvaluate;
this.options.interpolate = this.options.interpolate || utils.reInterpolate;
// register helpers
if (this.options.helpers) {
this.helpers(this.options.helpers);
}
// load data
if (this.options.data) {
this.data(this.options.data);
}
}
/**
* Register a template helper.
*
* ```js
* engine.helper('upper', function(str) {
* return str.toUpperCase();
* });
*
* engine.render('<%= upper(user) %>', {user: 'doowb'});
* //=> 'DOOWB'
* ```
* @param {String} `prop`
* @param {Function} `fn`
* @return {Object} Instance of `Engine` for chaining
* @api public
*/
helper(prop, fn) {
if (utils.isObject(prop)) {
this.helpers(prop);
} else {
this.imports[prop] = fn;
}
return this;
}
/**
* Register an object of template helpers.
*
* ```js
* engine.helpers({
* upper: function(str) {
* return str.toUpperCase();
* },
* lower: function(str) {
* return str.toLowerCase();
* }
* });
*
* // Or, just require in `template-helpers`
* engine.helpers(require('template-helpers')._);
* ```
* @param {Object|Array} `helpers` Object or array of helper objects.
* @return {Object} Instance of `Engine` for chaining
* @api public
*/
helpers(helpers) {
if (utils.isObject(helpers)) {
for (let key of Object.keys(helpers)) {
this.helper(key, helpers[key]);
}
}
return this;
}
/**
* Add data to be passed to templates as context.
*
* ```js
* engine.data({first: 'Brian'});
* engine.render('<%= last %>, <%= first %>', {last: 'Woodward'});
* //=> 'Woodward, Brian'
* ```
* @param {String|Object} `key` Property key, or an object
* @param {any} `value` If key is a string, this can be any typeof value
* @return {Object} Engine instance, for chaining
* @api public
*/
data(prop, value) {
if (typeof prop === 'object') {
if (utils.isObject(prop)) {
for (let key of Object.keys(prop)) {
this.data(key, prop[key]);
}
}
} else {
if (!this.cache.data) this.cache.data = {};
this.cache.data[prop] = value;
}
return this;
}
/**
* Generate the regex to use for matching template variables.
* @param {Object} `opts`
* @return {RegExp}
*/
_regex(options) {
let opts = Object.assign({}, this.options, options);
if (!opts.interpolate && !opts.regex && !opts.escape && !opts.evaluate) {
return utils.delimiters;
}
let interpolate = opts.interpolate || utils.reNoMatch;
if (opts.regex instanceof RegExp) {
interpolate = opts.regex;
}
let reString = (opts.escape || utils.reNoMatch).source
+ '|' + interpolate.source
+ '|' + (interpolate === utils.reInterpolate ? utils.reEsTemplate : utils.reNoMatch).source
+ '|' + (opts.evaluate || utils.reNoMatch).source;
return new RegExp(reString + '|$', 'g');
}
/**
* Creates a compiled template function that can interpolate data properties
* in "interpolate" delimiters, HTML-escape interpolated data properties in
* "escape" delimiters, and execute JavaScript in "evaluate" delimiters. Data
* properties may be accessed as free variables in the template. If a setting
* object is provided it takes precedence over `engine.settings` values.
*
* ```js
* let fn = engine.compile('Hello, <%= user %>!');
* //=> [function]
*
* fn({user: 'doowb'});
* //=> 'Hello, doowb!'
*
* fn({user: 'halle'});
* //=> 'Hello, halle!'
* ```
* @param {String} `str` The template string.
* @param {Object} `options` The options object.
* @param {RegExp} [options.escape] The HTML "escape" delimiter.
* @param {RegExp} [options.evaluate] The "evaluate" delimiter.
* @param {Object} [options.imports] An object to import into the template as free variables.
* @param {RegExp} [options.interpolate] The "interpolate" delimiter.
* @param {String} [options.sourceURL] The sourceURL of the template's compiled source.
* @param {String} [options.variable] The data object variable name.
* @param {Object} `settings` Engine settings
* @returns {Function} Returns the compiled template function.
* @api public
*/
compile(input, options = {}, settings) {
let engine = this;
if (!(this instanceof Engine)) {
if (!utils.isObject(options)) options = {};
engine = new Engine(options);
}
let str = String(input);
settings = assign({}, engine.settings, options && options.settings, settings);
let opts = assign({}, engine.options, settings, options);
let imports = assign({}, opts.imports, opts.helpers, settings.imports);
imports.escape = utils.escape;
assign(imports, utils.omit(engine.imports, 'engine'));
assign(imports, utils.omit(engine.cache.data, 'engine'));
imports.engine = engine;
let keys = Object.keys(imports);
let values = keys.map(key => imports[key]);
let isEvaluating;
let isEscaping;
let source = "__p += '";
let idx = 0;
// Use a sourceURL for easier debugging.
let sourceURL = '//# sourceURL='
+ ('sourceURL' in opts ? opts.sourceURL : (`engine.templateSources[${(++engine.counter)}]`))
+ '\n';
// Compile the regexp to match each delimiter.
let re = engine._regex(opts);
str.replace(re, (match, esc, interp, es6, evaluate, offset) => {
if (!interp) interp = es6;
// Escape characters that can't be included in str literals.
source += str.slice(idx, offset).replace(utils.reUnescapedString, utils.escapeStringChar);
// Replace delimiters with snippets.
if (esc) {
isEscaping = true;
source += `' +\n__e(${esc}) +\n'`;
}
if (evaluate) {
isEvaluating = true;
source += `';\n${evaluate};\n__p += '`;
}
if (interp) {
source += `' +\n((__t = (${interp})) == null ? '' : __t) +\n'`;
}
idx = offset + match.length;
// The JS engine embedded in Adobe products requires returning the `match`
// str in order to produce the correct `offset` value.
return match;
});
source += "';\n";
// If `variable` is not specified wrap a with-statement around the generated
// code to add the data object to the top of the scope chain.
let variable = opts.variable;
if (!variable) {
source = `with (obj) {\n${source}\n}\n`;
}
// Cleanup code by stripping empty strings.
source = (isEvaluating ? source.replace(utils.reEmptyStringLeading, '') : source)
.replace(utils.reEmptyStringMiddle, '$1')
.replace(utils.reEmptyStringTrailing, '$1;');
// Frame code as the function body.
source = 'function('
+ (variable || 'obj') + ') {\n'
+ (variable ? '' : '(obj || (obj = {}));\n')
+ 'let __t, __p = ""'
+ (isEscaping ? ', __e = escape' : '')
+ (isEvaluating ? ', __j = Array.prototype.join;\nfunction print() { __p += __j.call(arguments, "") }\n' : ';\n')
+ source + 'return __p\n}';
let result = utils.tryCatch(function() {
return Function(keys, sourceURL + `return ${source}`).apply(null, values);
});
// Provide the compiled function's source by its `toString` method or
// the `source` property as a convenience for inlining compiled templates.
result.source = source;
if (result instanceof Error) {
throw result;
}
return result;
}
/**
* Renders templates with the given data and returns a string.
*
* ```js
* engine.render('<%= user %>', {user: 'doowb'});
* //=> 'doowb'
* ```
* @param {String} `str`
* @param {Object} `data`
* @return {String}
* @api public
*/
render(fn, locals, options) {
let ctx = Object.assign({}, this.cache.data, locals);
if (ctx.imports) ctx = Object.assign({}, ctx, ctx.imports);
if (ctx.helpers) ctx = Object.assign({}, ctx, ctx.helpers);
if (typeof fn === 'string') {
fn = this.compile(fn, options);
}
return fn(ctx);
}
static get utils() {
return utils;
}
}
/**
* Expose `Engine`
*/
module.exports = Engine;