import { existsSync, readFileSync } from 'fs'; import * as path from 'path'; 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) { const 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) { const whitespace = str.slice(0, indx).split(/\n/); const lineNo = whitespace.length; const 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 */ const 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 (const 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) { let leftTrim; let 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 */ const 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. const newStr = String(str); if (/[&<>"']/.test(newStr)) { return newStr.replace(/[&<>"']/g, replaceChar); } else { return newStr; } } /* END TYPES */ const templateLitReg = /`(?:\\[\s\S]|\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})*}|(?!\${)[^\\`])*`/g; const singleQuoteReg = /'(?:\\[\s\w"'\\`]|[^\n\r'\\])*?'/g; const 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) { let buffer = []; let trimLeftOfNextStr = false; let lastIndex = 0; const parseOptions = config.parse; if (config.plugins) { for (let i = 0; i < config.plugins.length; i++) { const 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); } } } const 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; } }, ""); const parseOpenReg = new RegExp(escapeRegExp(config.tags[0]) + "(-|_)?\\s*(" + prefixes + ")?\\s*", "g"); const parseCloseReg = new RegExp("'|\"|`|\\/\\*|(\\s*(-|_)?" + escapeRegExp(config.tags[1]) + ")", "g"); // TODO: benchmark having the \s* on either side vs using str.trim() let m; while (m = parseOpenReg.exec(str)) { const precedingString = str.slice(lastIndex, m.index); lastIndex = m[0].length + m.index; const wsLeft = m[1]; const prefix = m[2] || ""; // by default either ~, =, or empty pushString(precedingString, wsLeft); parseCloseReg.lastIndex = lastIndex; let closeTag; let currentObj = false; while (closeTag = parseCloseReg.exec(str)) { if (closeTag[1]) { const content = str.slice(lastIndex, closeTag.index); parseOpenReg.lastIndex = lastIndex = parseCloseReg.lastIndex; trimLeftOfNextStr = closeTag[2]; const currentType = prefix === parseOptions.exec ? "e" : prefix === parseOptions.raw ? "r" : prefix === parseOptions.interpolate ? "i" : ""; currentObj = { t: currentType, val: content }; break; } else { const char = closeTag[0]; if (char === "/*") { const commentCloseInd = str.indexOf("*/", parseCloseReg.lastIndex); if (commentCloseInd === -1) { ParseErr("unclosed comment", str, closeTag.index); } parseCloseReg.lastIndex = commentCloseInd; } else if (char === "'") { singleQuoteReg.lastIndex = closeTag.index; const singleQuoteMatch = singleQuoteReg.exec(str); if (singleQuoteMatch) { parseCloseReg.lastIndex = singleQuoteReg.lastIndex; } else { ParseErr("unclosed string", str, closeTag.index); } } else if (char === '"') { doubleQuoteReg.lastIndex = closeTag.index; const doubleQuoteMatch = doubleQuoteReg.exec(str); if (doubleQuoteMatch) { parseCloseReg.lastIndex = doubleQuoteReg.lastIndex; } else { ParseErr("unclosed string", str, closeTag.index); } } else if (char === "`") { templateLitReg.lastIndex = closeTag.index; const 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 (let i = 0; i < config.plugins.length; i++) { const 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) { const buffer = parse(str, config); let 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 (let i = 0; i < config.plugins.length; i++) { const 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) { let i = 0; const buffLength = buff.length; let returnStr = ""; for (i; i < buffLength; i++) { const currentBlock = buff[i]; if (typeof currentBlock === "string") { const str = currentBlock; // we know string exists returnStr += "tR+='" + str + "'\n"; } else { const type = currentBlock.t; // ~, s, !, ?, r let 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` */ class Cacher { constructor(cache) { this.cache = void 0; this.cache = cache; } define(key, val) { this.cache[key] = val; } get(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]; } remove(key) { delete this.cache[key]; } reset() { this.cache = {}; } load(cacheObj) { copyProps(this.cache, cacheObj); } } /* END TYPES */ /** * Eta's template storage * * Stores partials and cached templates */ const 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) { const template = this.templates.get(templateNameOrPath); if (!template) { throw EtaErr('Could not fetch template "' + templateNameOrPath + '"'); } return template(data, this); } /** Eta's base (global) configuration */ const 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 const 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) { const options = getConfig(config || {}); /* ASYNC HANDLING */ // The below code is modified from mde/ejs. All credit should go to them. const 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; } } } const _BOM = /^\uFEFF/; /* END TYPES */ /** * Get the path to the included file from the parent file path and the * specified path. * * If `name` does not have an extension, it will default to `.eta` * * @param name specified path * @param parentfile parent file path * @param isDirectory whether parentfile is a directory * @return absolute path to template */ function getWholeFilePath(name, parentfile, isDirectory) { const includePath = path.resolve(isDirectory ? parentfile : path.dirname(parentfile), // returns directory the parent file is in name // file ) + (path.extname(name) ? "" : ".eta"); return includePath; } /** * Get the absolute path to an included template * * If this is called with an absolute path (for example, starting with '/' or 'C:\') * then Eta will attempt to resolve the absolute path within options.views. If it cannot, * Eta will fallback to options.root or '/' * * If this is called with a relative path, Eta will: * - Look relative to the current template (if the current template has the `filename` property) * - Look inside each directory in options.views * * Note: if Eta is unable to find a template using path and options, it will throw an error. * * @param path specified path * @param options compilation options * @return absolute path to template */ function getPath(path, options) { let includePath = false; const views = options.views; const searchedPaths = []; // If these four values are the same, // getPath() will return the same result every time. // We can cache the result to avoid expensive // file operations. const pathOptions = JSON.stringify({ filename: options.filename, path: path, root: options.root, views: options.views }); if (options.cache && options.filepathCache && options.filepathCache[pathOptions]) { // Use the cached filepath return options.filepathCache[pathOptions]; } /** Add a filepath to the list of paths we've checked for a template */ function addPathToSearched(pathSearched) { if (!searchedPaths.includes(pathSearched)) { searchedPaths.push(pathSearched); } } /** * Take a filepath (like 'partials/mypartial.eta'). Attempt to find the template file inside `views`; * return the resulting template file path, or `false` to indicate that the template was not found. * * @param views the filepath that holds templates, or an array of filepaths that hold templates * @param path the path to the template */ function searchViews(views, path) { let filePath; // If views is an array, then loop through each directory // And attempt to find the template if (Array.isArray(views) && views.some(function (v) { filePath = getWholeFilePath(path, v, true); addPathToSearched(filePath); return existsSync(filePath); })) { // If the above returned true, we know that the filePath was just set to a path // That exists (Array.some() returns as soon as it finds a valid element) return filePath; } else if (typeof views === "string") { // Search for the file if views is a single directory filePath = getWholeFilePath(path, views, true); addPathToSearched(filePath); if (existsSync(filePath)) { return filePath; } } // Unable to find a file return false; } // Path starts with '/', 'C:\', etc. const match = /^[A-Za-z]+:\\|^\//.exec(path); // Absolute path, like /partials/partial.eta if (match && match.length) { // We have to trim the beginning '/' off the path, or else // path.resolve(dir, path) will always resolve to just path const formattedPath = path.replace(/^\/*/, ""); // First, try to resolve the path within options.views includePath = searchViews(views, formattedPath); if (!includePath) { // If that fails, searchViews will return false. Try to find the path // inside options.root (by default '/', the base of the filesystem) const pathFromRoot = getWholeFilePath(formattedPath, options.root || "/", true); addPathToSearched(pathFromRoot); includePath = pathFromRoot; } } else { // Relative paths // Look relative to a passed filename first if (options.filename) { const filePath = getWholeFilePath(path, options.filename); addPathToSearched(filePath); if (existsSync(filePath)) { includePath = filePath; } } // Then look for the template in options.views if (!includePath) { includePath = searchViews(views, path); } if (!includePath) { throw EtaErr('Could not find the template "' + path + '". Paths tried: ' + searchedPaths); } } // If caching and filepathCache are enabled, // cache the input & output of this function. if (options.cache && options.filepathCache) { options.filepathCache[pathOptions] = includePath; } return includePath; } /** * Reads a file synchronously */ function readFile(filePath) { try { return readFileSync(filePath).toString().replace(_BOM, ""); // TODO: is replacing BOM's necessary? } catch { throw EtaErr("Failed to read template at '" + filePath + "'"); } } // express is set like: app.engine('html', require('eta').renderFile) /* END TYPES */ /** * Reads a template, compiles it into a function, caches it if caching isn't disabled, returns the function * * @param filePath Absolute path to template file * @param options Eta configuration overrides * @param noCache Optionally, make Eta not cache the template */ function loadFile(filePath, options, noCache) { const config = getConfig(options); const template = readFile(filePath); try { const compiledTemplate = compile(template, config); if (!noCache) { config.templates.define(config.filename, compiledTemplate); } return compiledTemplate; } catch (e) { throw EtaErr("Loading file: " + filePath + " failed:\n\n" + e.message); } } /** * Get the template from a string or a file, either compiled on-the-fly or * read from cache (if enabled), and cache the template if needed. * * If `options.cache` is true, this function reads the file from * `options.filename` so it must be set prior to calling this function. * * @param options compilation options * @return Eta template function */ function handleCache$1(options) { const filename = options.filename; if (options.cache) { const func = options.templates.get(filename); if (func) { return func; } return loadFile(filename, options); } // Caching is disabled, so pass noCache = true return loadFile(filename, options, true); } /** * Try calling handleCache with the given options and data and call the * callback with the result. If an error occurs, call the callback with * the error. Used by renderFile(). * * @param data template data * @param options compilation options * @param cb callback */ function tryHandleCache(data, options, cb) { if (cb) { try { // Note: if there is an error while rendering the template, // It will bubble up and be caught here const templateFn = handleCache$1(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 { const templateFn = handleCache$1(options); const result = templateFn(data, options); resolve(result); } catch (err) { reject(err); } }); } else { throw EtaErr("Please provide a callback function, this env doesn't support Promises"); } } } /** * Get the template function. * * If `options.cache` is `true`, then the template is cached. * * This returns a template function and the config object with which that template function should be called. * * @remarks * * It's important that this returns a config object with `filename` set. * Otherwise, the included file would not be able to use relative paths * * @param path path for the specified file (if relative, specify `views` on `options`) * @param options compilation options * @return [Eta template function, new config object] */ function includeFile(path, options) { // the below creates a new options object, using the parent filepath of the old options object and the path const newFileOptions = getConfig({ filename: getPath(path, options) }, options); // TODO: make sure properties are currectly copied over return [handleCache$1(newFileOptions), newFileOptions]; } function renderFile(filename, data, config, cb) { /* Here we have some function overloading. Essentially, the first 2 arguments to renderFile should always be the filename and data Express will call renderFile with (filename, data, cb) We also want to make (filename, data, options, cb) available */ let renderConfig; let callback; data = data || {}; // First, assign our callback function to `callback` // We can leave it undefined if neither parameter is a function; // Callbacks are optional if (typeof cb === "function") { // The 4th argument is the callback callback = cb; } else if (typeof config === "function") { // The 3rd arg is the callback callback = config; } // If there is a config object passed in explicitly, use it if (typeof config === "object") { renderConfig = getConfig(config || {}); } else { // Otherwise, get the default config renderConfig = getConfig({}); } // Set the filename option on the template // This will first try to resolve the file path (see getPath for details) renderConfig.filename = getPath(filename, renderConfig); return tryHandleCache(data, renderConfig, callback); } function renderFileAsync(filename, data, config, cb) { return renderFile(filename, typeof config === "function" ? { ...data, async: true } : data, typeof config === "object" ? { ...config, async: true } : config, cb); } /* END TYPES */ /** * Called with `includeFile(path, data)` */ function includeFileHelper(path, data) { const templateAndConfig = includeFile(path, this); return templateAndConfig[0](data, templateAndConfig[1]); } /* END TYPES */ function handleCache(template, options) { if (options.cache && options.name && options.templates.get(options.name)) { return options.templates.get(options.name); } const 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; } function render(template, data, config, cb) { const 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 const 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); } } 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); } // @denoify-ignore config.includeFile = includeFileHelper; config.filepathCache = {}; export { renderFile as __express, compile, compileToString, config, configure, config as defaultConfig, getConfig, loadFile, parse, render, renderAsync, renderFile, renderFileAsync, templates }; //# sourceMappingURL=eta.module.mjs.map