eta
Version:
Lightweight, fast, and powerful embedded JS template engine
690 lines (676 loc) • 24.9 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Eta = {}));
}(this, (function (exports) { 'use strict';
function setPrototypeOf(obj, proto) {
// eslint-disable-line @typescript-eslint/no-explicit-any
if (Object.setPrototypeOf) {
Object.setPrototypeOf(obj, proto);
}
else {
obj.__proto__ = proto;
}
}
// This is pretty much the only way to get nice, extended Errors
// without using ES6
/**
* This returns a new Error with a custom prototype. Note that it's _not_ a constructor
*
* @param message Error message
*
* **Example**
*
* ```js
* throw EtaErr("template not found")
* ```
*/
function EtaErr(message) {
var err = new Error(message);
setPrototypeOf(err, EtaErr.prototype);
return err;
}
EtaErr.prototype = Object.create(Error.prototype, {
name: { value: 'Eta Error', enumerable: false }
});
/**
* Throws an EtaErr with a nicely formatted error and message showing where in the template the error occurred.
*/
function ParseErr(message, str, indx) {
var whitespace = str.slice(0, indx).split(/\n/);
var lineNo = whitespace.length;
var colNo = whitespace[lineNo - 1].length + 1;
message +=
' at line ' +
lineNo +
' col ' +
colNo +
':\n\n' +
' ' +
str.split(/\n/)[lineNo - 1] +
'\n' +
' ' +
Array(colNo).join(' ') +
'^';
throw EtaErr(message);
}
/**
* @returns The global Promise function
*/
var promiseImpl = new Function('return this')().Promise;
/**
* @returns A new AsyncFunction constuctor
*/
function getAsyncFunctionConstructor() {
try {
return new Function('return (async function(){}).constructor')();
}
catch (e) {
if (e instanceof SyntaxError) {
throw EtaErr("This environment doesn't support async/await");
}
else {
throw e;
}
}
}
/**
* str.trimLeft polyfill
*
* @param str - Input string
* @returns The string with left whitespace removed
*
*/
function trimLeft(str) {
// eslint-disable-next-line no-extra-boolean-cast
if (!!String.prototype.trimLeft) {
return str.trimLeft();
}
else {
return str.replace(/^\s+/, '');
}
}
/**
* str.trimRight polyfill
*
* @param str - Input string
* @returns The string with right whitespace removed
*
*/
function trimRight(str) {
// eslint-disable-next-line no-extra-boolean-cast
if (!!String.prototype.trimRight) {
return str.trimRight();
}
else {
return str.replace(/\s+$/, ''); // TODO: do we really need to replace BOM's?
}
}
// TODO: allow '-' to trim up until newline. Use [^\S\n\r] instead of \s
/* END TYPES */
function hasOwnProp(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
function copyProps(toObj, fromObj) {
for (var key in fromObj) {
if (hasOwnProp(fromObj, key)) {
toObj[key] = fromObj[key];
}
}
return toObj;
}
/**
* Takes a string within a template and trims it, based on the preceding tag's whitespace control and `config.autoTrim`
*/
function trimWS(str, config, wsLeft, wsRight) {
var leftTrim;
var rightTrim;
if (Array.isArray(config.autoTrim)) {
// kinda confusing
// but _}} will trim the left side of the following string
leftTrim = config.autoTrim[1];
rightTrim = config.autoTrim[0];
}
else {
leftTrim = rightTrim = config.autoTrim;
}
if (wsLeft || wsLeft === false) {
leftTrim = wsLeft;
}
if (wsRight || wsRight === false) {
rightTrim = wsRight;
}
if (!rightTrim && !leftTrim) {
return str;
}
if (leftTrim === 'slurp' && rightTrim === 'slurp') {
return str.trim();
}
if (leftTrim === '_' || leftTrim === 'slurp') {
// console.log('trimming left' + leftTrim)
// full slurp
str = trimLeft(str);
}
else if (leftTrim === '-' || leftTrim === 'nl') {
// nl trim
str = str.replace(/^(?:\r\n|\n|\r)/, '');
}
if (rightTrim === '_' || rightTrim === 'slurp') {
// full slurp
str = trimRight(str);
}
else if (rightTrim === '-' || rightTrim === 'nl') {
// nl trim
str = str.replace(/(?:\r\n|\n|\r)$/, ''); // TODO: make sure this gets \r\n
}
return str;
}
/**
* A map of special HTML characters to their XML-escaped equivalents
*/
var escMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
function replaceChar(s) {
return escMap[s];
}
/**
* XML-escapes an input value after converting it to a string
*
* @param str - Input value (usually a string)
* @returns XML-escaped string
*/
function XMLEscape(str) {
// eslint-disable-line @typescript-eslint/no-explicit-any
// To deal with XSS. Based on Escape implementations of Mustache.JS and Marko, then customized.
var newStr = String(str);
if (/[&<>"']/.test(newStr)) {
return newStr.replace(/[&<>"']/g, replaceChar);
}
else {
return newStr;
}
}
/* END TYPES */
var templateLitReg = /`(?:\\[\s\S]|\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})*}|(?!\${)[^\\`])*`/g;
var singleQuoteReg = /'(?:\\[\s\w"'\\`]|[^\n\r'\\])*?'/g;
var doubleQuoteReg = /"(?:\\[\s\w"'\\`]|[^\n\r"\\])*?"/g;
/** Escape special regular expression characters inside a string */
function escapeRegExp(string) {
// From MDN
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function parse(str, config) {
var buffer = [];
var trimLeftOfNextStr = false;
var lastIndex = 0;
var parseOptions = config.parse;
if (config.plugins) {
for (var i = 0; i < config.plugins.length; i++) {
var plugin = config.plugins[i];
if (plugin.processTemplate) {
str = plugin.processTemplate(str, config);
}
}
}
/* Adding for EJS compatibility */
if (config.rmWhitespace) {
// Code taken directly from EJS
// Have to use two separate replaces here as `^` and `$` operators don't
// work well with `\r` and empty lines don't work well with the `m` flag.
// Essentially, this replaces the whitespace at the beginning and end of
// each line and removes multiple newlines.
str = str.replace(/[\r\n]+/g, '\n').replace(/^\s+|\s+$/gm, '');
}
/* End rmWhitespace option */
templateLitReg.lastIndex = 0;
singleQuoteReg.lastIndex = 0;
doubleQuoteReg.lastIndex = 0;
function pushString(strng, shouldTrimRightOfString) {
if (strng) {
// if string is truthy it must be of type 'string'
strng = trimWS(strng, config, trimLeftOfNextStr, // this will only be false on the first str, the next ones will be null or undefined
shouldTrimRightOfString);
if (strng) {
// replace \ with \\, ' with \'
// we're going to convert all CRLF to LF so it doesn't take more than one replace
strng = strng.replace(/\\|'/g, '\\$&').replace(/\r\n|\n|\r/g, '\\n');
buffer.push(strng);
}
}
}
var prefixes = [parseOptions.exec, parseOptions.interpolate, parseOptions.raw].reduce(function (accumulator, prefix) {
if (accumulator && prefix) {
return accumulator + '|' + escapeRegExp(prefix);
}
else if (prefix) {
// accumulator is falsy
return escapeRegExp(prefix);
}
else {
// prefix and accumulator are both falsy
return accumulator;
}
}, '');
var parseOpenReg = new RegExp('([^]*?)' + escapeRegExp(config.tags[0]) + '(-|_)?\\s*(' + prefixes + ')?\\s*', 'g');
var parseCloseReg = new RegExp('\'|"|`|\\/\\*|(\\s*(-|_)?' + escapeRegExp(config.tags[1]) + ')', 'g');
// TODO: benchmark having the \s* on either side vs using str.trim()
var m;
while ((m = parseOpenReg.exec(str))) {
lastIndex = m[0].length + m.index;
var precedingString = m[1];
var wsLeft = m[2];
var prefix = m[3] || ''; // by default either ~, =, or empty
pushString(precedingString, wsLeft);
parseCloseReg.lastIndex = lastIndex;
var closeTag = void 0;
var currentObj = false;
while ((closeTag = parseCloseReg.exec(str))) {
if (closeTag[1]) {
var content = str.slice(lastIndex, closeTag.index);
parseOpenReg.lastIndex = lastIndex = parseCloseReg.lastIndex;
trimLeftOfNextStr = closeTag[2];
var currentType = prefix === parseOptions.exec
? 'e'
: prefix === parseOptions.raw
? 'r'
: prefix === parseOptions.interpolate
? 'i'
: '';
currentObj = { t: currentType, val: content };
break;
}
else {
var char = closeTag[0];
if (char === '/*') {
var commentCloseInd = str.indexOf('*/', parseCloseReg.lastIndex);
if (commentCloseInd === -1) {
ParseErr('unclosed comment', str, closeTag.index);
}
parseCloseReg.lastIndex = commentCloseInd;
}
else if (char === "'") {
singleQuoteReg.lastIndex = closeTag.index;
var singleQuoteMatch = singleQuoteReg.exec(str);
if (singleQuoteMatch) {
parseCloseReg.lastIndex = singleQuoteReg.lastIndex;
}
else {
ParseErr('unclosed string', str, closeTag.index);
}
}
else if (char === '"') {
doubleQuoteReg.lastIndex = closeTag.index;
var doubleQuoteMatch = doubleQuoteReg.exec(str);
if (doubleQuoteMatch) {
parseCloseReg.lastIndex = doubleQuoteReg.lastIndex;
}
else {
ParseErr('unclosed string', str, closeTag.index);
}
}
else if (char === '`') {
templateLitReg.lastIndex = closeTag.index;
var templateLitMatch = templateLitReg.exec(str);
if (templateLitMatch) {
parseCloseReg.lastIndex = templateLitReg.lastIndex;
}
else {
ParseErr('unclosed string', str, closeTag.index);
}
}
}
}
if (currentObj) {
buffer.push(currentObj);
}
else {
ParseErr('unclosed tag', str, m.index + precedingString.length);
}
}
pushString(str.slice(lastIndex, str.length), false);
if (config.plugins) {
for (var i = 0; i < config.plugins.length; i++) {
var plugin = config.plugins[i];
if (plugin.processAST) {
buffer = plugin.processAST(buffer, config);
}
}
}
return buffer;
}
/* END TYPES */
/**
* Compiles a template string to a function string. Most often users just use `compile()`, which calls `compileToString` and creates a new function using the result
*
* **Example**
*
* ```js
* compileToString("Hi <%= it.user %>", eta.config)
* // "var tR='',include=E.include.bind(E),includeFile=E.includeFile.bind(E);tR+='Hi ';tR+=E.e(it.user);if(cb){cb(null,tR)} return tR"
* ```
*/
function compileToString(str, config) {
var buffer = parse(str, config);
var res = "var tR='',__l,__lP" +
(config.include ? ',include=E.include.bind(E)' : '') +
(config.includeFile ? ',includeFile=E.includeFile.bind(E)' : '') +
'\nfunction layout(p,d){__l=p;__lP=d}\n' +
(config.useWith ? 'with(' + config.varName + '||{}){' : '') +
compileScope(buffer, config) +
(config.includeFile
? 'if(__l)tR=' +
(config.async ? 'await ' : '') +
("includeFile(__l,Object.assign(" + config.varName + ",{body:tR},__lP))\n")
: config.include
? 'if(__l)tR=' +
(config.async ? 'await ' : '') +
("include(__l,Object.assign(" + config.varName + ",{body:tR},__lP))\n")
: '') +
'if(cb){cb(null,tR)} return tR' +
(config.useWith ? '}' : '');
if (config.plugins) {
for (var i = 0; i < config.plugins.length; i++) {
var plugin = config.plugins[i];
if (plugin.processFnString) {
res = plugin.processFnString(res, config);
}
}
}
return res;
}
/**
* Loops through the AST generated by `parse` and transform each item into JS calls
*
* **Example**
*
* ```js
* // AST version of 'Hi <%= it.user %>'
* let templateAST = ['Hi ', { val: 'it.user', t: 'i' }]
* compileScope(templateAST, eta.config)
* // "tR+='Hi ';tR+=E.e(it.user);"
* ```
*/
function compileScope(buff, config) {
var i = 0;
var buffLength = buff.length;
var returnStr = '';
for (i; i < buffLength; i++) {
var currentBlock = buff[i];
if (typeof currentBlock === 'string') {
var str = currentBlock;
// we know string exists
returnStr += "tR+='" + str + "'\n";
}
else {
var type = currentBlock.t; // ~, s, !, ?, r
var content = currentBlock.val || '';
if (type === 'r') {
// raw
if (config.filter) {
content = 'E.filter(' + content + ')';
}
returnStr += 'tR+=' + content + '\n';
}
else if (type === 'i') {
// interpolate
if (config.filter) {
content = 'E.filter(' + content + ')';
}
if (config.autoEscape) {
content = 'E.e(' + content + ')';
}
returnStr += 'tR+=' + content + '\n';
// reference
}
else if (type === 'e') {
// execute
returnStr += content + '\n'; // you need a \n in case you have <% } %>
}
}
}
return returnStr;
}
/**
* Handles storage and accessing of values
*
* In this case, we use it to store compiled template functions
* Indexed by their `name` or `filename`
*/
var Cacher = /** @class */ (function () {
function Cacher(cache) {
this.cache = cache;
}
Cacher.prototype.define = function (key, val) {
this.cache[key] = val;
};
Cacher.prototype.get = function (key) {
// string | array.
// TODO: allow array of keys to look down
// TODO: create plugin to allow referencing helpers, filters with dot notation
return this.cache[key];
};
Cacher.prototype.remove = function (key) {
delete this.cache[key];
};
Cacher.prototype.reset = function () {
this.cache = {};
};
Cacher.prototype.load = function (cacheObj) {
copyProps(this.cache, cacheObj);
};
return Cacher;
}());
/* END TYPES */
/**
* Eta's template storage
*
* Stores partials and cached templates
*/
var templates = new Cacher({});
/* END TYPES */
/**
* Include a template based on its name (or filepath, if it's already been cached).
*
* Called like `include(templateNameOrPath, data)`
*/
function includeHelper(templateNameOrPath, data) {
var template = this.templates.get(templateNameOrPath);
if (!template) {
throw EtaErr('Could not fetch template "' + templateNameOrPath + '"');
}
return template(data, this);
}
/** Eta's base (global) configuration */
var config = {
async: false,
autoEscape: true,
autoTrim: [false, 'nl'],
cache: false,
e: XMLEscape,
include: includeHelper,
parse: {
exec: '',
interpolate: '=',
raw: '~'
},
plugins: [],
rmWhitespace: false,
tags: ['<%', '%>'],
templates: templates,
useWith: false,
varName: 'it'
};
/**
* Takes one or two partial (not necessarily complete) configuration objects, merges them 1 layer deep into eta.config, and returns the result
*
* @param override Partial configuration object
* @param baseConfig Partial configuration object to merge before `override`
*
* **Example**
*
* ```js
* let customConfig = getConfig({tags: ['!#', '#!']})
* ```
*/
function getConfig(override, baseConfig) {
// TODO: run more tests on this
var res = {}; // Linked
copyProps(res, config); // Creates deep clone of eta.config, 1 layer deep
if (baseConfig) {
copyProps(res, baseConfig);
}
if (override) {
copyProps(res, override);
}
return res;
}
/** Update Eta's base config */
function configure(options) {
return copyProps(config, options);
}
/* END TYPES */
/**
* Takes a template string and returns a template function that can be called with (data, config, [cb])
*
* @param str - The template string
* @param config - A custom configuration object (optional)
*
* **Example**
*
* ```js
* let compiledFn = eta.compile("Hi <%= it.user %>")
* // function anonymous()
* let compiledFnStr = compiledFn.toString()
* // "function anonymous(it,E,cb\n) {\nvar tR='',include=E.include.bind(E),includeFile=E.includeFile.bind(E);tR+='Hi ';tR+=E.e(it.user);if(cb){cb(null,tR)} return tR\n}"
* ```
*/
function compile(str, config) {
var options = getConfig(config || {});
/* ASYNC HANDLING */
// The below code is modified from mde/ejs. All credit should go to them.
var ctor = options.async ? getAsyncFunctionConstructor() : Function;
/* END ASYNC HANDLING */
try {
return new ctor(options.varName, 'E', // EtaConfig
'cb', // optional callback
compileToString(str, options)); // eslint-disable-line no-new-func
}
catch (e) {
if (e instanceof SyntaxError) {
throw EtaErr('Bad template syntax\n\n' +
e.message +
'\n' +
Array(e.message.length + 1).join('=') +
'\n' +
compileToString(str, options) +
'\n' // This will put an extra newline before the callstack for extra readability
);
}
else {
throw e;
}
}
}
/* END TYPES */
function handleCache(template, options) {
if (options.cache && options.name && options.templates.get(options.name)) {
return options.templates.get(options.name);
}
var templateFunc = typeof template === 'function' ? template : compile(template, options);
// Note that we don't have to check if it already exists in the cache;
// it would have returned earlier if it had
if (options.cache && options.name) {
options.templates.define(options.name, templateFunc);
}
return templateFunc;
}
/**
* Render a template
*
* If `template` is a string, Eta will compile it to a function and then call it with the provided data.
* If `template` is a template function, Eta will call it with the provided data.
*
* If `config.async` is `false`, Eta will return the rendered template.
*
* If `config.async` is `true` and there's a callback function, Eta will call the callback with `(err, renderedTemplate)`.
* If `config.async` is `true` and there's not a callback function, Eta will return a Promise that resolves to the rendered template.
*
* If `config.cache` is `true` and `config` has a `name` or `filename` property, Eta will cache the template on the first render and use the cached template for all subsequent renders.
*
* @param template Template string or template function
* @param data Data to render the template with
* @param config Optional config options
* @param cb Callback function
*/
function render(template, data, config, cb) {
var options = getConfig(config || {});
if (options.async) {
if (cb) {
// If user passes callback
try {
// Note: if there is an error while rendering the template,
// It will bubble up and be caught here
var templateFn = handleCache(template, options);
templateFn(data, options, cb);
}
catch (err) {
return cb(err);
}
}
else {
// No callback, try returning a promise
if (typeof promiseImpl === 'function') {
return new promiseImpl(function (resolve, reject) {
try {
resolve(handleCache(template, options)(data, options));
}
catch (err) {
reject(err);
}
});
}
else {
throw EtaErr("Please provide a callback function, this env doesn't support Promises");
}
}
}
else {
return handleCache(template, options)(data, options);
}
}
/**
* Render a template asynchronously
*
* If `template` is a string, Eta will compile it to a function and call it with the provided data.
* If `template` is a function, Eta will call it with the provided data.
*
* If there is a callback function, Eta will call it with `(err, renderedTemplate)`.
* If there is not a callback function, Eta will return a Promise that resolves to the rendered template
*
* @param template Template string or template function
* @param data Data to render the template with
* @param config Optional config options
* @param cb Callback function
*/
function renderAsync(template, data, config, cb) {
// Using Object.assign to lower bundle size, using spread operator makes it larger because of typescript injected polyfills
return render(template, data, Object.assign({}, config, { async: true }), cb);
}
exports.compile = compile;
exports.compileToString = compileToString;
exports.config = config;
exports.configure = configure;
exports.defaultConfig = config;
exports.getConfig = getConfig;
exports.parse = parse;
exports.render = render;
exports.renderAsync = renderAsync;
exports.templates = templates;
Object.defineProperty(exports, '__esModule', { value: true });
})));
//# sourceMappingURL=eta.dev.js.map