import os from "node:os"; import fs from "node:fs"; import matter from "gray-matter"; import lodash from "@11ty/lodash-custom"; import { TemplatePath } from "@11ty/eleventy-utils"; import debugUtil from "debug"; import TemplateData from "./Data/TemplateData.js"; import TemplateRender from "./TemplateRender.js"; import EleventyBaseError from "./Errors/EleventyBaseError.js"; import EleventyErrorUtil from "./Errors/EleventyErrorUtil.js"; import eventBus from "./EventBus.js"; import { withResolvers } from "./Util/PromiseUtil.js"; const { set: lodashSet } = lodash; const debug = debugUtil("Eleventy:TemplateContent"); const debugDev = debugUtil("Dev:Eleventy:TemplateContent"); class TemplateContentFrontMatterError extends EleventyBaseError {} class TemplateContentCompileError extends EleventyBaseError {} class TemplateContentRenderError extends EleventyBaseError {} class TemplateContent { #initialized = false; #config; #templateRender; #preprocessorEngine; #extensionMap; #configOptions; constructor(inputPath, templateConfig) { if (!templateConfig || templateConfig.constructor.name !== "TemplateConfig") { throw new Error("Missing or invalid `templateConfig` argument"); } this.eleventyConfig = templateConfig; this.inputPath = inputPath; } async asyncTemplateInitialization() { if (!this.hasTemplateRender()) { await this.getTemplateRender(); } if (this.#initialized) { return; } this.#initialized = true; let preprocessorEngineName = this.templateRender.getPreprocessorEngineName(); if (preprocessorEngineName && this.templateRender.engine.getName() !== preprocessorEngineName) { let engine = await this.templateRender.getEngineByName(preprocessorEngineName); this.#preprocessorEngine = engine; } } resetCachedTemplate({ eleventyConfig }) { this.eleventyConfig = eleventyConfig; } get dirs() { return this.eleventyConfig.directories; } get inputDir() { return this.dirs.input; } get outputDir() { return this.dirs.output; } getResetTypes(types) { if (types) { return Object.assign( { data: false, read: false, render: false, }, types, ); } return { data: true, read: true, render: true, }; } // Called during an incremental build when the template instance is cached but needs to be reset because it has changed resetCaches(types) { types = this.getResetTypes(types); if (types.read) { delete this.readingPromise; delete this.inputContent; delete this._frontMatterDataCache; } if (types.render) { this.#templateRender = undefined; } } get extensionMap() { if (!this.#extensionMap) { throw new Error("Internal error: Missing `extensionMap` in TemplateContent."); } return this.#extensionMap; } set extensionMap(map) { this.#extensionMap = map; } set eleventyConfig(config) { this.#config = config; if (this.#config.constructor.name === "TemplateConfig") { this.#configOptions = this.#config.getConfig(); } else { throw new Error("Tried to get an TemplateConfig but none was found."); } } get eleventyConfig() { if (this.#config.constructor.name === "TemplateConfig") { return this.#config; } throw new Error("Tried to get an TemplateConfig but none was found."); } get config() { if (this.#config.constructor.name === "TemplateConfig" && !this.#configOptions) { this.#configOptions = this.#config.getConfig(); } return this.#configOptions; } get bench() { return this.config.benchmarkManager.get("Aggregate"); } get engine() { return this.templateRender.engine; } get templateRender() { if (!this.hasTemplateRender()) { throw new Error(`\`templateRender\` has not yet initialized on ${this.inputPath}`); } return this.#templateRender; } hasTemplateRender() { return !!this.#templateRender; } async getTemplateRender() { if (!this.#templateRender) { this.#templateRender = new TemplateRender(this.inputPath, this.eleventyConfig); this.#templateRender.extensionMap = this.extensionMap; return this.#templateRender.init().then(() => { return this.#templateRender; }); } return this.#templateRender; } // For monkey patchers get frontMatter() { if (this.frontMatterOverride) { return this.frontMatterOverride; } else { throw new Error( "Unfortunately you’re using code that monkey patched some Eleventy internals and it isn’t async-friendly. Change your code to use the async `read()` method on the template instead!", ); } } // For monkey patchers set frontMatter(contentOverride) { this.frontMatterOverride = contentOverride; } getInputPath() { return this.inputPath; } getInputDir() { return this.inputDir; } isVirtualTemplate() { let def = this.getVirtualTemplateDefinition(); return !!def; } getVirtualTemplateDefinition() { let inputDirRelativeInputPath = this.eleventyConfig.directories.getInputPathRelativeToInputDirectory(this.inputPath); return this.config.virtualTemplates[inputDirRelativeInputPath]; } async #read() { let content = await this.inputContent; if (content || content === "") { let tr = await this.getTemplateRender(); if (tr.engine.useJavaScriptImport()) { return { data: {}, content, }; } let options = this.config.frontMatterParsingOptions || {}; let fm; try { // Added in 3.0, passed along to front matter engines options.filePath = this.inputPath; fm = matter(content, options); } catch (e) { throw new TemplateContentFrontMatterError( `Having trouble reading front matter from template ${this.inputPath}`, e, ); } if (options.excerpt && fm.excerpt) { let excerptString = fm.excerpt + (options.excerpt_separator || "---"); if (fm.content.startsWith(excerptString + os.EOL)) { // with an os-specific newline after excerpt separator fm.content = fm.excerpt.trim() + "\n" + fm.content.slice((excerptString + os.EOL).length); } else if (fm.content.startsWith(excerptString + "\n")) { // with a newline (\n) after excerpt separator // This is necessary for some git configurations on windows fm.content = fm.excerpt.trim() + "\n" + fm.content.slice((excerptString + 1).length); } else if (fm.content.startsWith(excerptString)) { // no newline after excerpt separator fm.content = fm.excerpt + fm.content.slice(excerptString.length); } // alias, defaults to page.excerpt let alias = options.excerpt_alias || "page.excerpt"; lodashSet(fm.data, alias, fm.excerpt); } // For monkey patchers that used `frontMatter` 🤧 // https://github.com/11ty/eleventy/issues/613#issuecomment-999637109 // https://github.com/11ty/eleventy/issues/2710#issuecomment-1373854834 // Removed this._frontMatter monkey patcher help in 3.0.0-alpha.7 return fm; } else { return { data: {}, content: "", excerpt: "", }; } } async read() { if (!this.readingPromise) { if (!this.inputContent) { // @cachedproperty this.inputContent = this.getInputContent(); } // @cachedproperty this.readingPromise = this.#read(); } return this.readingPromise; } /* Incremental builds cache the Template instances (in TemplateWriter) but * these template specific caches are important for Pagination */ static cache(path, content) { this._inputCache.set(TemplatePath.absolutePath(path), content); } static getCached(path) { return this._inputCache.get(TemplatePath.absolutePath(path)); } static deleteFromInputCache(path) { this._inputCache.delete(TemplatePath.absolutePath(path)); } // Used via clone setInputContent(content) { this.inputContent = content; } async getInputContent() { let tr = await this.getTemplateRender(); let virtualTemplateDefinition = this.getVirtualTemplateDefinition(); if (virtualTemplateDefinition) { let { content } = virtualTemplateDefinition; return content; } if ( tr.engine.useJavaScriptImport() && typeof tr.engine.getInstanceFromInputPath === "function" ) { return tr.engine.getInstanceFromInputPath(this.inputPath); } if (!tr.engine.needsToReadFileContents()) { return ""; } let templateBenchmark = this.bench.get("Template Read"); templateBenchmark.before(); let content; if (this.config.useTemplateCache) { content = TemplateContent.getCached(this.inputPath); } if (!content && content !== "") { let contentBuffer = fs.readFileSync(this.inputPath); content = contentBuffer.toString("utf8"); if (this.config.useTemplateCache) { TemplateContent.cache(this.inputPath, content); } } templateBenchmark.after(); return content; } async _testGetFrontMatter() { let fm = this.frontMatterOverride ? this.frontMatterOverride : await this.read(); return fm; } async getPreRender() { let fm = this.frontMatterOverride ? this.frontMatterOverride : await this.read(); return fm.content; } async #getFrontMatterData() { let fm = await this.read(); // gray-matter isn’t async-friendly but can return a promise from custom front matter if (fm.data instanceof Promise) { fm.data = await fm.data; } let tr = await this.getTemplateRender(); let extraData = await tr.engine.getExtraDataFromFile(this.inputPath); let virtualTemplateDefinition = this.getVirtualTemplateDefinition(); let virtualTemplateData; if (virtualTemplateDefinition) { virtualTemplateData = virtualTemplateDefinition.data; } let data = Object.assign(fm.data, extraData, virtualTemplateData); TemplateData.cleanupData(data, { file: this.inputPath, isVirtualTemplate: Boolean(virtualTemplateData), }); return { data, excerpt: fm.excerpt, }; } async getFrontMatterData() { if (!this._frontMatterDataCache) { // @cachedproperty this._frontMatterDataCache = this.#getFrontMatterData(); } return this._frontMatterDataCache; } async getEngineOverride() { return this.getFrontMatterData().then((data) => { return data[this.config.keys.engineOverride]; }); } // checks engines isTemplateCacheable() { if (this.#preprocessorEngine) { return this.#preprocessorEngine.cacheable; } return this.engine.cacheable; } _getCompileCache(str) { // Caches used to be bifurcated based on engine name, now they’re based on inputPath // TODO does `cacheable` need to help inform whether a cache is used here? let inputPathMap = TemplateContent._compileCache.get(this.inputPath); if (!inputPathMap) { inputPathMap = new Map(); TemplateContent._compileCache.set(this.inputPath, inputPathMap); } let cacheable = this.isTemplateCacheable(); let { useCache, key } = this.engine.getCompileCacheKey(str, this.inputPath); // We also tie the compile cache key to the UserConfig instance, to alleviate issues with global template cache // Better to move the cache to the Eleventy instance instead, no? // (This specifically failed I18nPluginTest cases with filters being cached across tests and not having access to each plugin’s options) key = this.eleventyConfig.userConfig._getUniqueId() + key; return [cacheable, key, inputPathMap, useCache]; } async compile(str, options = {}) { let { type, bypassMarkdown, engineOverride } = options; // Must happen before cacheable fetch below // Likely only necessary for Eleventy Layouts, see TemplateMap->initDependencyMap await this.asyncTemplateInitialization(); // this.templateRender is guaranteed here let tr = await this.getTemplateRender(); if (engineOverride !== undefined) { debugDev("%o overriding template engine to use %o", this.inputPath, engineOverride); await tr.setEngineOverride(engineOverride, bypassMarkdown); } else { tr.setUseMarkdown(!bypassMarkdown); } if (bypassMarkdown && !this.engine.needsCompilation(str)) { return function () { return str; }; } debugDev("%o compile() using engine: %o", this.inputPath, tr.engineName); try { let res; if (this.config.useTemplateCache) { let [cacheable, key, cache, useCache] = this._getCompileCache(str); if (cacheable && key) { if (useCache && cache.has(key)) { this.bench.get("(count) Template Compile Cache Hit").incrementCount(); return cache.get(key); } this.bench.get("(count) Template Compile Cache Miss").incrementCount(); // Compile cache is cleared when the resource is modified (below) // Compilation is async, so we eagerly cache a Promise that eventually // resolves to the compiled function let withRes = withResolvers(); res = withRes.resolve; cache.set(key, withRes.promise); } } let typeStr = type ? ` ${type}` : ""; let templateBenchmark = this.bench.get(`Template Compile${typeStr}`); let inputPathBenchmark = this.bench.get(`> Compile${typeStr} > ${this.inputPath}`); templateBenchmark.before(); inputPathBenchmark.before(); let fn = await tr.getCompiledTemplate(str); inputPathBenchmark.after(); templateBenchmark.after(); debugDev("%o getCompiledTemplate function created", this.inputPath); if (this.config.useTemplateCache && res) { res(fn); } return fn; } catch (e) { let [cacheable, key, cache] = this._getCompileCache(str); if (cacheable && key) { cache.delete(key); } debug(`Having trouble compiling template ${this.inputPath}: %O`, str); throw new TemplateContentCompileError( `Having trouble compiling template ${this.inputPath}`, e, ); } } getParseForSymbolsFunction(str) { let engine = this.engine; // Don’t use markdown as the engine to parse for symbols // TODO pass in engineOverride here if (this.#preprocessorEngine) { engine = this.#preprocessorEngine; } if ("parseForSymbols" in engine) { return () => { if (Array.isArray(str)) { return str .filter((entry) => typeof entry === "string") .map((entry) => engine.parseForSymbols(entry)) .flat(); } if (typeof str === "string") { return engine.parseForSymbols(str); } return []; }; } } // used by computed data or for permalink functions async _renderFunction(fn, ...args) { let mixins = Object.assign({}, this.config.javascriptFunctions); let result = await fn.call(mixins, ...args); // normalize Buffer away if returned from permalink if (Buffer.isBuffer(result)) { return result.toString(); } return result; } async renderComputedData(str, data) { if (typeof str === "function") { return this._renderFunction(str, data); } return this._render(str, data, { type: "Computed Data", bypassMarkdown: true, }); } async renderPermalink(permalink, data) { let tr = await this.getTemplateRender(); let permalinkCompilation = tr.engine.permalinkNeedsCompilation(permalink); // No string compilation: // ({ compileOptions: { permalink: "raw" }}) // These mean `permalink: false`, which is no file system writing: // ({ compileOptions: { permalink: false }}) // ({ compileOptions: { permalink: () => false }}) // ({ compileOptions: { permalink: () => (() = > false) }}) if (permalinkCompilation === false && typeof permalink !== "function") { return permalink; } /* Custom `compile` function for permalinks, usage: permalink: function(permalinkString, inputPath) { return async function(data) { return "THIS IS MY RENDERED PERMALINK"; } } */ if (permalinkCompilation && typeof permalinkCompilation === "function") { permalink = await this._renderFunction(permalinkCompilation, permalink, this.inputPath); } // Raw permalink function (in the app code data cascade) if (typeof permalink === "function") { return this._renderFunction(permalink, data); } return this._render(permalink, data, { type: "Permalink", bypassMarkdown: true, }); } async render(str, data, bypassMarkdown) { return this._render(str, data, { type: "Content", bypassMarkdown, }); } _getPaginationLogSuffix(data) { let suffix = []; if ("pagination" in data) { suffix.push(" ("); if (data.pagination.pages) { suffix.push( `${data.pagination.pages.length} page${data.pagination.pages.length !== 1 ? "s" : ""}`, ); } else { suffix.push("Pagination"); } suffix.push(")"); } return suffix.join(""); } async _render(str, data, options = {}) { let { bypassMarkdown, type } = options; try { if (bypassMarkdown && !this.engine.needsCompilation(str)) { return str; } let fn = await this.compile(str, { bypassMarkdown, engineOverride: data[this.config.keys.engineOverride], type, }); if (fn === undefined) { return; } else if (typeof fn !== "function") { throw new Error(`The \`compile\` function did not return a function. Received ${fn}`); } // Benchmark let templateBenchmark = this.bench.get("Render"); let inputPathBenchmark = this.bench.get( `> Render${type ? ` ${type}` : ""} > ${this.inputPath}${this._getPaginationLogSuffix(data)}`, ); templateBenchmark.before(); if (inputPathBenchmark) { inputPathBenchmark.before(); } let rendered = await fn(data); if (inputPathBenchmark) { inputPathBenchmark.after(); } templateBenchmark.after(); debugDev("%o getCompiledTemplate called, rendered content created", this.inputPath); return rendered; } catch (e) { if (EleventyErrorUtil.isPrematureTemplateContentError(e)) { return Promise.reject(e); } else { let tr = await this.getTemplateRender(); let engine = tr.getReadableEnginesList(); debug(`Having trouble rendering ${engine} template ${this.inputPath}: %O`, str); return Promise.reject( new TemplateContentRenderError( `Having trouble rendering ${engine} template ${this.inputPath}`, e, ), ); } } } getExtensionEntries() { return this.engine.extensionEntries; } isFileRelevantToThisTemplate(incrementalFile, metadata = {}) { // always relevant if incremental file not set (build everything) if (!incrementalFile) { return true; } let hasDependencies = this.engine.hasDependencies(incrementalFile); let isRelevant = this.engine.isFileRelevantTo(this.inputPath, incrementalFile); debug( "Test dependencies to see if %o is relevant to %o: %o", this.inputPath, incrementalFile, isRelevant, ); let extensionEntries = this.getExtensionEntries().filter((entry) => !!entry.isIncrementalMatch); if (extensionEntries.length) { for (let entry of extensionEntries) { if ( entry.isIncrementalMatch.call( { inputPath: this.inputPath, isFullTemplate: metadata.isFullTemplate, isFileRelevantToInputPath: isRelevant, doesFileHaveDependencies: hasDependencies, }, incrementalFile, ) ) { return true; } } return false; } else { // Not great way of building all templates if this is a layout, include, JS dependency. // TODO improve this for default template syntaxes // This is the fallback way of determining if something is incremental (no isIncrementalMatch available) // This will be true if the inputPath and incrementalFile are the same if (isRelevant) { return true; } // only return true here if dependencies are not known if (!hasDependencies && !metadata.isFullTemplate) { return true; } } return false; } } TemplateContent._inputCache = new Map(); TemplateContent._compileCache = new Map(); eventBus.on("eleventy.resourceModified", (path) => { // delete from input cache TemplateContent.deleteFromInputCache(path); // delete from compile cache let normalized = TemplatePath.addLeadingDotSlash(path); let compileCache = TemplateContent._compileCache.get(normalized); if (compileCache) { compileCache.clear(); } }); // Used when the configuration file reset https://github.com/11ty/eleventy/issues/2147 eventBus.on("eleventy.compileCacheReset", () => { TemplateContent._compileCache = new Map(); }); export default TemplateContent;