import NunjucksLib from "nunjucks"; import { TemplatePath } from "@11ty/eleventy-utils"; import TemplateEngine from "./TemplateEngine.js"; import EleventyBaseError from "../Errors/EleventyBaseError.js"; import EventBusUtil from "../Util/EventBusUtil.js"; import { augmentObject } from "./Util/ContextAugmenter.js"; class EleventyNunjucksError extends EleventyBaseError {} class Nunjucks extends TemplateEngine { constructor(name, eleventyConfig) { super(name, eleventyConfig); this.nunjucksEnvironmentOptions = this.config.nunjucksEnvironmentOptions || { dev: true }; this.nunjucksPrecompiledTemplates = this.config.nunjucksPrecompiledTemplates || {}; this._usingPrecompiled = Object.keys(this.nunjucksPrecompiledTemplates).length > 0; this.setLibrary(this.config.libraryOverrides.njk); this.cacheable = true; } _setEnv(override) { if (override) { this.njkEnv = override; } else if (this._usingPrecompiled) { // Precompiled templates to avoid eval! const NodePrecompiledLoader = function () {}; NodePrecompiledLoader.prototype.getSource = (name) => { // https://github.com/mozilla/nunjucks/blob/fd500902d7c88672470c87170796de52fc0f791a/nunjucks/src/precompiled-loader.js#L5 return { src: { type: "code", obj: this.nunjucksPrecompiledTemplates[name], }, // Maybe add this? // path, // noCache: true }; }; this.njkEnv = new NunjucksLib.Environment( new NodePrecompiledLoader(), this.nunjucksEnvironmentOptions, ); } else { let paths = new Set(); paths.add(super.getIncludesDir()); paths.add(TemplatePath.getWorkingDir()); // Filter out undefined paths let fsLoader = new NunjucksLib.FileSystemLoader(Array.from(paths).filter(Boolean)); this.njkEnv = new NunjucksLib.Environment(fsLoader, this.nunjucksEnvironmentOptions); } this.config.events.emit("eleventy.engine.njk", { nunjucks: NunjucksLib, environment: this.njkEnv, }); } setLibrary(override) { this._setEnv(override); // Correct, but overbroad. Better would be to evict more granularly, but // resolution from paths isn't straightforward. EventBusUtil.soloOn("eleventy.templateModified", (/*path*/) => { this.njkEnv.invalidateCache(); }); this.setEngineLib(this.njkEnv); this.addFilters(this.config.nunjucksFilters); this.addFilters(this.config.nunjucksAsyncFilters, true); // TODO these all go to the same place (addTag), add warnings for overwrites // TODO(zachleat): variableName should work with quotes or without quotes (same as {% set %}) this.addPairedShortcode("setAsync", function (content, variableName) { this.ctx[variableName] = content; return ""; }); this.addCustomTags(this.config.nunjucksTags); this.addAllShortcodes(this.config.nunjucksShortcodes); this.addAllShortcodes(this.config.nunjucksAsyncShortcodes, true); this.addAllPairedShortcodes(this.config.nunjucksPairedShortcodes); this.addAllPairedShortcodes(this.config.nunjucksAsyncPairedShortcodes, true); this.addGlobals(this.config.nunjucksGlobals); } addFilters(filters, isAsync) { for (let name in filters) { this.njkEnv.addFilter(name, Nunjucks.wrapFilter(name, filters[name]), isAsync); } } static wrapFilter(name, fn) { return function (...args) { try { augmentObject(this, { source: this.ctx, lazy: false, // context.env?.opts.throwOnUndefined, }); return fn.call(this, ...args); } catch (e) { throw new EleventyNunjucksError( `Error in Nunjucks Filter \`${name}\`${this.page ? ` (${this.page.inputPath})` : ""}`, e, ); } }; } // Shortcodes static normalizeContext(context) { let obj = {}; if (context.ctx) { obj.ctx = context.ctx; obj.env = context.env; augmentObject(obj, { source: context.ctx, lazy: false, // context.env?.opts.throwOnUndefined, }); } return obj; } addCustomTags(tags) { for (let name in tags) { this.addTag(name, tags[name]); } } addTag(name, tagFn) { let tagObj; if (typeof tagFn === "function") { tagObj = tagFn(NunjucksLib, this.njkEnv); } else { throw new Error( "Nunjucks.addTag expects a callback function to be passed in: addTag(name, function(nunjucksEngine) {})", ); } this.njkEnv.addExtension(name, tagObj); } addGlobals(globals) { for (let name in globals) { this.addGlobal(name, globals[name]); } } addGlobal(name, globalFn) { this.njkEnv.addGlobal(name, globalFn); } addAllShortcodes(shortcodes, isAsync = false) { for (let name in shortcodes) { this.addShortcode(name, shortcodes[name], isAsync); } } addAllPairedShortcodes(shortcodes, isAsync = false) { for (let name in shortcodes) { this.addPairedShortcode(name, shortcodes[name], isAsync); } } _getShortcodeFn(shortcodeName, shortcodeFn, isAsync = false) { return function ShortcodeFunction() { this.tags = [shortcodeName]; this.parse = function (parser, nodes) { let args; let tok = parser.nextToken(); args = parser.parseSignature(true, true); // Nunjucks bug with non-paired custom tags bug still exists even // though this issue is closed. Works fine for paired. // https://github.com/mozilla/nunjucks/issues/158 if (args.children.length === 0) { args.addChild(new nodes.Literal(0, 0, "")); } parser.advanceAfterBlockEnd(tok.value); if (isAsync) { return new nodes.CallExtensionAsync(this, "run", args); } return new nodes.CallExtension(this, "run", args); }; this.run = function (...args) { let resolve; if (isAsync) { resolve = args.pop(); } let [context, ...argArray] = args; if (isAsync) { let ret = shortcodeFn.call(Nunjucks.normalizeContext(context), ...argArray); // #3286 error messaging when the shortcode is not a promise if (!ret?.then) { resolve( new EleventyNunjucksError( `Error with Nunjucks shortcode \`${shortcodeName}\`: it was defined as asynchronous but was actually synchronous. This is important for Nunjucks.`, ), ); } ret.then( function (returnValue) { resolve(null, new NunjucksLib.runtime.SafeString("" + returnValue)); }, function (e) { resolve( new EleventyNunjucksError(`Error with Nunjucks shortcode \`${shortcodeName}\``, e), ); }, ); } else { try { let ret = shortcodeFn.call(Nunjucks.normalizeContext(context), ...argArray); return new NunjucksLib.runtime.SafeString("" + ret); } catch (e) { throw new EleventyNunjucksError( `Error with Nunjucks shortcode \`${shortcodeName}\``, e, ); } } }; }; } _getPairedShortcodeFn(shortcodeName, shortcodeFn, isAsync = false) { return function PairedShortcodeFunction() { this.tags = [shortcodeName]; this.parse = function (parser, nodes) { var tok = parser.nextToken(); var args = parser.parseSignature(true, true); parser.advanceAfterBlockEnd(tok.value); var body = parser.parseUntilBlocks("end" + shortcodeName); parser.advanceAfterBlockEnd(); return new nodes.CallExtensionAsync(this, "run", args, [body]); }; this.run = function (...args) { let resolve = args.pop(); let body = args.pop(); let [context, ...argArray] = args; body(function (e, bodyContent) { if (e) { resolve( new EleventyNunjucksError( `Error with Nunjucks paired shortcode \`${shortcodeName}\``, e, ), ); } if (isAsync) { let ret = shortcodeFn.call( Nunjucks.normalizeContext(context), bodyContent, ...argArray, ); // #3286 error messaging when the shortcode is not a promise if (!ret?.then) { throw new EleventyNunjucksError( `Error with Nunjucks shortcode \`${shortcodeName}\`: it was defined as asynchronous but was actually synchronous. This is important for Nunjucks.`, ); } ret.then( function (returnValue) { resolve(null, new NunjucksLib.runtime.SafeString(returnValue)); }, function (e) { resolve( new EleventyNunjucksError( `Error with Nunjucks paired shortcode \`${shortcodeName}\``, e, ), ); }, ); } else { try { resolve( null, new NunjucksLib.runtime.SafeString( shortcodeFn.call(Nunjucks.normalizeContext(context), bodyContent, ...argArray), ), ); } catch (e) { resolve( new EleventyNunjucksError( `Error with Nunjucks paired shortcode \`${shortcodeName}\``, e, ), ); } } }); }; }; } addShortcode(shortcodeName, shortcodeFn, isAsync = false) { let fn = this._getShortcodeFn(shortcodeName, shortcodeFn, isAsync); this.njkEnv.addExtension(shortcodeName, new fn()); } addPairedShortcode(shortcodeName, shortcodeFn, isAsync = false) { let fn = this._getPairedShortcodeFn(shortcodeName, shortcodeFn, isAsync); this.njkEnv.addExtension(shortcodeName, new fn()); } // Don’t return a boolean if permalink is a function (see TemplateContent->renderPermalink) permalinkNeedsCompilation(str) { if (typeof str === "string") { return this.needsCompilation(str); } } needsCompilation(str) { // Defend against syntax customisations: // https://mozilla.github.io/nunjucks/api.html#customizing-syntax let optsTags = this.njkEnv.opts.tags || {}; let blockStart = optsTags.blockStart || "{%"; let variableStart = optsTags.variableStart || "{{"; let commentStart = optsTags.variableStart || "{#"; return ( str.indexOf(blockStart) !== -1 || str.indexOf(variableStart) !== -1 || str.indexOf(commentStart) !== -1 ); } _getParseExtensions() { if (this._parseExtensions) { return this._parseExtensions; } // add extensions so the parser knows about our custom tags/blocks let ext = []; for (let name in this.config.nunjucksTags) { let fn = this._getShortcodeFn(name, () => {}); ext.push(new fn()); } for (let name in this.config.nunjucksShortcodes) { let fn = this._getShortcodeFn(name, () => {}); ext.push(new fn()); } for (let name in this.config.nunjucksAsyncShortcodes) { let fn = this._getShortcodeFn(name, () => {}, true); ext.push(new fn()); } for (let name in this.config.nunjucksPairedShortcodes) { let fn = this._getPairedShortcodeFn(name, () => {}); ext.push(new fn()); } for (let name in this.config.nunjucksAsyncPairedShortcodes) { let fn = this._getPairedShortcodeFn(name, () => {}, true); ext.push(new fn()); } this._parseExtensions = ext; return ext; } /* Outputs an Array of lodash get selectors */ parseForSymbols(str) { const { parser, nodes } = NunjucksLib; let obj = parser.parse(str, this._getParseExtensions()); let linesplit = str.split("\n"); let values = obj.findAll(nodes.Value); let symbols = obj.findAll(nodes.Symbol).map((entry) => { let name = [entry.value]; let nestedIndex = -1; for (let val of values) { if (nestedIndex > -1) { /* deep.object.syntax */ if (linesplit[val.lineno].charAt(nestedIndex) === ".") { name.push(val.value); nestedIndex += val.value.length + 1; } else { nestedIndex = -1; } } else if ( val.lineno === entry.lineno && val.colno === entry.colno && val.value === entry.value ) { nestedIndex = entry.colno + entry.value.length; } } return name.join("."); }); let uniqueSymbols = Array.from(new Set(symbols)); return uniqueSymbols; } async compile(str, inputPath) { let tmpl; // *All* templates are precompiled to avoid runtime eval if (this._usingPrecompiled) { tmpl = this.njkEnv.getTemplate(str, true); } else if (!inputPath || inputPath === "njk" || inputPath === "md") { tmpl = new NunjucksLib.Template(str, this.njkEnv, null, false); } else { tmpl = new NunjucksLib.Template(str, this.njkEnv, inputPath, false); } return function (data) { return new Promise(function (resolve, reject) { tmpl.render(data, function (err, res) { if (err) { reject(err); } else { resolve(res); } }); }); }; } } export default Nunjucks;