UNPKG

eta

Version:

Lightweight, fast, and powerful embedded JS template engine

690 lines (676 loc) 24.9 kB
(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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; 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