import fs from "node:fs"; import { Merge, TemplatePath, isPlainObject } from "@11ty/eleventy-utils"; import { evalToken } from "liquidjs"; // TODO add a first-class Markdown component to expose this using Markdown-only syntax (will need to be synchronous for markdown-it) import { ProxyWrap } from "../Util/Objects/ProxyWrap.js"; import TemplateDataInitialGlobalData from "../Data/TemplateDataInitialGlobalData.js"; import EleventyBaseError from "../Errors/EleventyBaseError.js"; import TemplateRender from "../TemplateRender.js"; import ProjectDirectories from "../Util/ProjectDirectories.js"; import TemplateConfig from "../TemplateConfig.js"; import EleventyExtensionMap from "../EleventyExtensionMap.js"; import TemplateEngineManager from "../Engines/TemplateEngineManager.js"; import Liquid from "../Engines/Liquid.js"; class EleventyNunjucksError extends EleventyBaseError {} /** @this {object} */ async function compile(content, templateLang, options = {}) { let { templateConfig, extensionMap } = options; let strictMode = options.strictMode ?? false; if (!templateConfig) { templateConfig = new TemplateConfig(null, false); templateConfig.setDirectories(new ProjectDirectories()); await templateConfig.init(); } // Breaking change in 2.0+, previous default was `html` and now we default to the page template syntax if (!templateLang) { templateLang = this.page.templateSyntax; } if (!extensionMap) { if (strictMode) { throw new Error("Internal error: missing `extensionMap` in RenderPlugin->compile."); } extensionMap = new EleventyExtensionMap(templateConfig); extensionMap.engineManager = new TemplateEngineManager(templateConfig); } let tr = new TemplateRender(templateLang, templateConfig); tr.extensionMap = extensionMap; if (templateLang) { await tr.setEngineOverride(templateLang); } else { await tr.init(); } // TODO tie this to the class, not the extension if ( tr.engine.name === "11ty.js" || tr.engine.name === "11ty.cjs" || tr.engine.name === "11ty.mjs" ) { throw new Error( "11ty.js is not yet supported as a template engine for `renderTemplate`. Use `renderFile` instead!", ); } return tr.getCompiledTemplate(content); } // No templateLang default, it should infer from the inputPath. async function compileFile(inputPath, options = {}, templateLang) { let { templateConfig, extensionMap, config } = options; let strictMode = options.strictMode ?? false; if (!inputPath) { throw new Error("Missing file path argument passed to the `renderFile` shortcode."); } let wasTemplateConfigMissing = false; if (!templateConfig) { templateConfig = new TemplateConfig(null, false); templateConfig.setDirectories(new ProjectDirectories()); wasTemplateConfigMissing = true; } if (config && typeof config === "function") { await config(templateConfig.userConfig); } if (wasTemplateConfigMissing) { await templateConfig.init(); } let normalizedPath = TemplatePath.normalizeOperatingSystemFilePath(inputPath); // Prefer the exists cache, if it’s available if (!templateConfig.existsCache.exists(normalizedPath)) { throw new Error( "Could not find render plugin file for the `renderFile` shortcode, looking for: " + inputPath, ); } if (!extensionMap) { if (strictMode) { throw new Error("Internal error: missing `extensionMap` in RenderPlugin->compileFile."); } extensionMap = new EleventyExtensionMap(templateConfig); extensionMap.engineManager = new TemplateEngineManager(templateConfig); } let tr = new TemplateRender(inputPath, templateConfig); tr.extensionMap = extensionMap; if (templateLang) { await tr.setEngineOverride(templateLang); } else { await tr.init(); } if (!tr.engine.needsToReadFileContents()) { return tr.getCompiledTemplate(null); } // TODO we could make this work with full templates (with front matter?) let content = fs.readFileSync(inputPath, "utf8"); return tr.getCompiledTemplate(content); } /** @this {object} */ async function renderShortcodeFn(fn, data) { if (fn === undefined) { return; } else if (typeof fn !== "function") { throw new Error(`The \`compile\` function did not return a function. Received ${fn}`); } // if the user passes a string or other literal, remap to an object. if (!isPlainObject(data)) { data = { _: data, }; } if ("data" in this && isPlainObject(this.data)) { // when options.accessGlobalData is true, this allows the global data // to be accessed inside of the shortcode as a fallback data = ProxyWrap(data, this.data); } else { // save `page` and `eleventy` for reuse data.page = this.page; data.eleventy = this.eleventy; } return fn(data); } /** * @module 11ty/eleventy/Plugins/RenderPlugin */ /** * A plugin to add shortcodes to render an Eleventy template * string (or file) inside of another template. {@link https://v3.11ty.dev/docs/plugins/render/} * * @since 1.0.0 * @param {module:11ty/eleventy/UserConfig} eleventyConfig - User-land configuration instance. * @param {object} options - Plugin options */ function eleventyRenderPlugin(eleventyConfig, options = {}) { let templateConfig; eleventyConfig.on("eleventy.config", (tmplConfigInstance) => { templateConfig = tmplConfigInstance; }); let extensionMap; eleventyConfig.on("eleventy.extensionmap", (map) => { extensionMap = map; }); /** * @typedef {object} options * @property {string} [tagName] - The shortcode name to render a template string. * @property {string} [tagNameFile] - The shortcode name to render a template file. * @property {module:11ty/eleventy/TemplateConfig} [templateConfig] - Configuration object * @property {boolean} [accessGlobalData] - Whether or not the template has access to the page’s data. */ let defaultOptions = { tagName: "renderTemplate", tagNameFile: "renderFile", filterName: "renderContent", templateConfig: null, accessGlobalData: false, }; let opts = Object.assign(defaultOptions, options); function liquidTemplateTag(liquidEngine, tagName) { // via https://github.com/harttle/liquidjs/blob/b5a22fa0910c708fe7881ef170ed44d3594e18f3/src/builtin/tags/raw.ts return { parse: function (tagToken, remainTokens) { this.name = tagToken.name; if (eleventyConfig.liquid.parameterParsing === "builtin") { this.orderedArgs = Liquid.parseArgumentsBuiltin(tagToken.args); // note that Liquid does have a Hash class for name-based argument parsing but offers no easy to support both modes in one class } else { this.legacyArgs = tagToken.args; } this.tokens = []; var stream = liquidEngine.parser .parseStream(remainTokens) .on("token", (token) => { if (token.name === "end" + tagName) stream.stop(); else this.tokens.push(token); }) .on("end", () => { throw new Error(`tag ${tagToken.getText()} not closed`); }); stream.start(); }, render: function* (ctx) { let normalizedContext = {}; if (ctx) { if (opts.accessGlobalData) { // parent template data cascade normalizedContext.data = ctx.getAll(); } normalizedContext.page = ctx.get(["page"]); normalizedContext.eleventy = ctx.get(["eleventy"]); } let argArray = []; if (this.legacyArgs) { let rawArgs = Liquid.parseArguments(null, this.legacyArgs); for (let arg of rawArgs) { let b = yield liquidEngine.evalValue(arg, ctx); argArray.push(b); } } else if (this.orderedArgs) { for (let arg of this.orderedArgs) { let b = yield evalToken(arg, ctx); argArray.push(b); } } // plaintext paired shortcode content let body = this.tokens.map((token) => token.getText()).join(""); let ret = _renderStringShortcodeFn.call( normalizedContext, body, // templateLang, data ...argArray, ); yield ret; return ret; }, }; } // TODO I don’t think this works with whitespace control, e.g. {%- endrenderTemplate %} function nunjucksTemplateTag(NunjucksLib, tagName) { return new (function () { this.tags = [tagName]; this.parse = function (parser, nodes) { var tok = parser.nextToken(); var args = parser.parseSignature(true, true); const begun = parser.advanceAfterBlockEnd(tok.value); // This code was ripped from the Nunjucks parser for `raw` // https://github.com/mozilla/nunjucks/blob/fd500902d7c88672470c87170796de52fc0f791a/nunjucks/src/parser.js#L655 const endTagName = "end" + tagName; // Look for upcoming raw blocks (ignore all other kinds of blocks) const rawBlockRegex = new RegExp( "([\\s\\S]*?){%\\s*(" + tagName + "|" + endTagName + ")\\s*(?=%})%}", ); let rawLevel = 1; let str = ""; let matches = null; // Exit when there's nothing to match // or when we've found the matching "endraw" block while ((matches = parser.tokens._extractRegex(rawBlockRegex)) && rawLevel > 0) { const all = matches[0]; const pre = matches[1]; const blockName = matches[2]; // Adjust rawlevel if (blockName === tagName) { rawLevel += 1; } else if (blockName === endTagName) { rawLevel -= 1; } // Add to str if (rawLevel === 0) { // We want to exclude the last "endraw" str += pre; // Move tokenizer to beginning of endraw block parser.tokens.backN(all.length - pre.length); } else { str += all; } } let body = new nodes.Output(begun.lineno, begun.colno, [ new nodes.TemplateData(begun.lineno, begun.colno, str), ]); return new nodes.CallExtensionAsync(this, "run", args, [body]); }; this.run = function (...args) { let resolve = args.pop(); let body = args.pop(); let [context, ...argArray] = args; let normalizedContext = {}; if (context.ctx?.page) { normalizedContext.ctx = context.ctx; // TODO .data // if(opts.accessGlobalData) { // normalizedContext.data = context.ctx; // } normalizedContext.page = context.ctx.page; normalizedContext.eleventy = context.ctx.eleventy; } body(function (e, bodyContent) { if (e) { resolve( new EleventyNunjucksError(`Error with Nunjucks paired shortcode \`${tagName}\``, e), ); } Promise.resolve( _renderStringShortcodeFn.call( normalizedContext, bodyContent, // templateLang, data ...argArray, ), ).then( function (returnValue) { resolve(null, new NunjucksLib.runtime.SafeString(returnValue)); }, function (e) { resolve( new EleventyNunjucksError(`Error with Nunjucks paired shortcode \`${tagName}\``, e), null, ); }, ); }); }; })(); } /** @this {object} */ async function _renderStringShortcodeFn(content, templateLang, data = {}) { // Default is fn(content, templateLang, data) but we want to support fn(content, data) too if (typeof templateLang !== "string") { data = templateLang; templateLang = false; } // TODO Render plugin `templateLang` is feeding bad input paths to the addDependencies call in Custom.js let fn = await compile.call(this, content, templateLang, { templateConfig: opts.templateConfig || templateConfig, extensionMap, }); return renderShortcodeFn.call(this, fn, data); } /** @this {object} */ async function _renderFileShortcodeFn(inputPath, data = {}, templateLang) { let options = { templateConfig: opts.templateConfig || templateConfig, extensionMap, }; let fn = await compileFile.call(this, inputPath, options, templateLang); return renderShortcodeFn.call(this, fn, data); } // Render strings if (opts.tagName) { // use falsy to opt-out eleventyConfig.addJavaScriptFunction(opts.tagName, _renderStringShortcodeFn); eleventyConfig.addLiquidTag(opts.tagName, function (liquidEngine) { return liquidTemplateTag(liquidEngine, opts.tagName); }); eleventyConfig.addNunjucksTag(opts.tagName, function (nunjucksLib) { return nunjucksTemplateTag(nunjucksLib, opts.tagName); }); } // Filter for rendering strings if (opts.filterName) { eleventyConfig.addAsyncFilter(opts.filterName, _renderStringShortcodeFn); } // Render File // use `false` to opt-out if (opts.tagNameFile) { eleventyConfig.addAsyncShortcode(opts.tagNameFile, _renderFileShortcodeFn); } } // Will re-use the same configuration instance both at a top level and across any nested renders class RenderManager { /** @type {Promise|undefined} */ #hasConfigInitialized; #extensionMap; #templateConfig; constructor() { this.templateConfig = new TemplateConfig(null, false); this.templateConfig.setDirectories(new ProjectDirectories()); } get templateConfig() { return this.#templateConfig; } set templateConfig(templateConfig) { if (!templateConfig || templateConfig === this.#templateConfig) { return; } this.#templateConfig = templateConfig; // This is the only plugin running on the Edge this.#templateConfig.userConfig.addPlugin(eleventyRenderPlugin, { templateConfig: this.#templateConfig, accessGlobalData: true, }); this.#extensionMap = new EleventyExtensionMap(this.#templateConfig); this.#extensionMap.engineManager = new TemplateEngineManager(this.#templateConfig); } async init() { if (this.#hasConfigInitialized) { return this.#hasConfigInitialized; } if (this.templateConfig.hasInitialized()) { return true; } this.#hasConfigInitialized = this.templateConfig.init(); await this.#hasConfigInitialized; return true; } // `callback` is async-friendly but requires await upstream config(callback) { // run an extra `function(eleventyConfig)` configuration callbacks if (callback && typeof callback === "function") { return callback(this.templateConfig.userConfig); } } get initialGlobalData() { if (!this._data) { this._data = new TemplateDataInitialGlobalData(this.templateConfig); } return this._data; } // because we don’t have access to the full data cascade—but // we still want configuration data added via `addGlobalData` async getData(...data) { await this.init(); let globalData = await this.initialGlobalData.getData(); let merged = Merge({}, globalData, ...data); return merged; } async compile(content, templateLang, options = {}) { await this.init(); options.templateConfig = this.templateConfig; options.extensionMap = this.#extensionMap; options.strictMode = true; // We don’t need `compile.call(this)` here because the Edge always uses "liquid" as the template lang (instead of relying on this.page.templateSyntax) // returns promise return compile(content, templateLang, options); } async render(fn, edgeData, buildTimeData) { await this.init(); let mergedData = await this.getData(edgeData); // Set .data for options.accessGlobalData feature let context = { data: mergedData, }; return renderShortcodeFn.call(context, fn, buildTimeData); } } Object.defineProperty(eleventyRenderPlugin, "eleventyPackage", { value: "@11ty/eleventy/render-plugin", }); Object.defineProperty(eleventyRenderPlugin, "eleventyPluginOptions", { value: { unique: true, }, }); export default eleventyRenderPlugin; export { compileFile as File, compile as String, RenderManager };