1201 lines
33 KiB
JavaScript
Executable File
1201 lines
33 KiB
JavaScript
Executable File
import util from "node:util";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import fs from "node:fs";
|
||
|
||
import lodash from "@11ty/lodash-custom";
|
||
import { DateTime } from "luxon";
|
||
import { TemplatePath, isPlainObject } from "@11ty/eleventy-utils";
|
||
import debugUtil from "debug";
|
||
import chalk from "kleur";
|
||
|
||
import ConsoleLogger from "./Util/ConsoleLogger.js";
|
||
import getDateFromGitLastUpdated from "./Util/DateGitLastUpdated.js";
|
||
import getDateFromGitFirstAdded from "./Util/DateGitFirstAdded.js";
|
||
import TemplateData from "./Data/TemplateData.js";
|
||
import TemplateContent from "./TemplateContent.js";
|
||
import TemplatePermalink from "./TemplatePermalink.js";
|
||
import TemplateLayout from "./TemplateLayout.js";
|
||
import TemplateFileSlug from "./TemplateFileSlug.js";
|
||
import ComputedData from "./Data/ComputedData.js";
|
||
import Pagination from "./Plugins/Pagination.js";
|
||
import TemplateBehavior from "./TemplateBehavior.js";
|
||
import TemplateContentPrematureUseError from "./Errors/TemplateContentPrematureUseError.js";
|
||
import TemplateContentUnrenderedTemplateError from "./Errors/TemplateContentUnrenderedTemplateError.js";
|
||
import EleventyBaseError from "./Errors/EleventyBaseError.js";
|
||
import ReservedData from "./Util/ReservedData.js";
|
||
import TransformsUtil from "./Util/TransformsUtil.js";
|
||
import { FileSystemManager } from "./Util/FileSystemManager.js";
|
||
|
||
const { set: lodashSet, get: lodashGet } = lodash;
|
||
const fsStat = util.promisify(fs.stat);
|
||
|
||
const debug = debugUtil("Eleventy:Template");
|
||
const debugDev = debugUtil("Dev:Eleventy:Template");
|
||
|
||
class Template extends TemplateContent {
|
||
#logger;
|
||
#fsManager;
|
||
|
||
constructor(templatePath, templateData, extensionMap, config) {
|
||
debugDev("new Template(%o)", templatePath);
|
||
super(templatePath, config);
|
||
|
||
this.parsed = path.parse(templatePath);
|
||
|
||
// for pagination
|
||
this.extraOutputSubdirectory = "";
|
||
|
||
this.extensionMap = extensionMap;
|
||
this.templateData = templateData;
|
||
this.#initFileSlug();
|
||
|
||
this.linters = [];
|
||
this.transforms = {};
|
||
|
||
this.isVerbose = true;
|
||
this.isDryRun = false;
|
||
this.writeCount = 0;
|
||
|
||
this.fileSlug = new TemplateFileSlug(this.inputPath, this.extensionMap, this.eleventyConfig);
|
||
this.fileSlugStr = this.fileSlug.getSlug();
|
||
this.filePathStem = this.fileSlug.getFullPathWithoutExtension();
|
||
|
||
this.outputFormat = "fs";
|
||
|
||
this.behavior = new TemplateBehavior(this.config);
|
||
this.behavior.setOutputFormat(this.outputFormat);
|
||
}
|
||
|
||
#initFileSlug() {
|
||
this.fileSlug = new TemplateFileSlug(this.inputPath, this.extensionMap, this.eleventyConfig);
|
||
this.fileSlugStr = this.fileSlug.getSlug();
|
||
this.filePathStem = this.fileSlug.getFullPathWithoutExtension();
|
||
}
|
||
|
||
/* mimic constructor arg order */
|
||
resetCachedTemplate({ templateData, extensionMap, eleventyConfig }) {
|
||
super.resetCachedTemplate({ eleventyConfig });
|
||
this.templateData = templateData;
|
||
this.extensionMap = extensionMap;
|
||
// this.#fsManager = undefined;
|
||
this.#initFileSlug();
|
||
}
|
||
|
||
get fsManager() {
|
||
if (!this.#fsManager) {
|
||
this.#fsManager = new FileSystemManager(this.eleventyConfig);
|
||
}
|
||
return this.#fsManager;
|
||
}
|
||
|
||
get logger() {
|
||
if (!this.#logger) {
|
||
this.#logger = new ConsoleLogger();
|
||
this.#logger.isVerbose = this.isVerbose;
|
||
}
|
||
return this.#logger;
|
||
}
|
||
|
||
/* Setter for Logger */
|
||
set logger(logger) {
|
||
this.#logger = logger;
|
||
}
|
||
|
||
isRenderable() {
|
||
return this.behavior.isRenderable();
|
||
}
|
||
|
||
isRenderableDisabled() {
|
||
return this.behavior.isRenderableDisabled();
|
||
}
|
||
|
||
isRenderableOptional() {
|
||
// A template that is lazily rendered once if used by a second order dependency of another template dependency.
|
||
// e.g. You change firstpost.md, which is used by feed.xml, but secondpost.md (also used by feed.xml)
|
||
// has not yet rendered and needs to be rendered once to populate the cache.
|
||
return this.behavior.isRenderableOptional();
|
||
}
|
||
|
||
setRenderableOverride(renderableOverride) {
|
||
this.behavior.setRenderableOverride(renderableOverride);
|
||
}
|
||
|
||
reset() {
|
||
this.renderCount = 0;
|
||
this.writeCount = 0;
|
||
}
|
||
|
||
resetCaches(types) {
|
||
types = this.getResetTypes(types);
|
||
|
||
super.resetCaches(types);
|
||
|
||
if (types.data) {
|
||
delete this._dataCache;
|
||
// delete this._usePermalinkRoot;
|
||
// delete this._stats;
|
||
}
|
||
|
||
if (types.render) {
|
||
delete this._cacheRenderedPromise;
|
||
delete this._cacheRenderedTransformsAndLayoutsPromise;
|
||
}
|
||
}
|
||
|
||
setOutputFormat(to) {
|
||
this.outputFormat = to;
|
||
this.behavior.setOutputFormat(to);
|
||
}
|
||
|
||
setIsVerbose(isVerbose) {
|
||
this.isVerbose = isVerbose;
|
||
this.logger.isVerbose = isVerbose;
|
||
}
|
||
|
||
setDryRunViaIncremental(isIncremental) {
|
||
this.isDryRun = isIncremental;
|
||
this.isIncremental = isIncremental;
|
||
}
|
||
|
||
setDryRun(isDryRun) {
|
||
this.isDryRun = !!isDryRun;
|
||
}
|
||
|
||
setExtraOutputSubdirectory(dir) {
|
||
this.extraOutputSubdirectory = dir + "/";
|
||
}
|
||
|
||
getTemplateSubfolder() {
|
||
let dir = TemplatePath.absolutePath(this.parsed.dir);
|
||
let inputDir = TemplatePath.absolutePath(this.inputDir);
|
||
return TemplatePath.stripLeadingSubPath(dir, inputDir);
|
||
}
|
||
|
||
templateUsesLayouts(pageData) {
|
||
if (this.hasTemplateRender()) {
|
||
return pageData?.[this.config.keys.layout] && this.templateRender.engine.useLayouts();
|
||
}
|
||
|
||
// If `layout` prop is set, default to true when engine is unknown
|
||
return Boolean(pageData?.[this.config.keys.layout]);
|
||
}
|
||
|
||
getLayout(layoutKey) {
|
||
// already cached downstream in TemplateLayout -> TemplateCache
|
||
try {
|
||
return TemplateLayout.getTemplate(layoutKey, this.eleventyConfig, this.extensionMap);
|
||
} catch (e) {
|
||
throw new EleventyBaseError(
|
||
`Problem creating an Eleventy Layout for the "${this.inputPath}" template file.`,
|
||
e,
|
||
);
|
||
}
|
||
}
|
||
|
||
get baseFile() {
|
||
return this.extensionMap.removeTemplateExtension(this.parsed.base);
|
||
}
|
||
|
||
async _getRawPermalinkInstance(permalinkValue) {
|
||
let perm = new TemplatePermalink(permalinkValue, this.extraOutputSubdirectory);
|
||
perm.setUrlTransforms(this.config.urlTransforms);
|
||
|
||
this.behavior.setFromPermalink(perm);
|
||
|
||
return perm;
|
||
}
|
||
|
||
async _getLink(data) {
|
||
if (!data) {
|
||
throw new Error("Internal error: data argument missing in Template->_getLink");
|
||
}
|
||
|
||
let permalink =
|
||
data[this.config.keys.permalink] ??
|
||
data?.[this.config.keys.computed]?.[this.config.keys.permalink];
|
||
let permalinkValue;
|
||
|
||
// `permalink: false` means render but no file system write, e.g. use in collections only)
|
||
// `permalink: true` throws an error
|
||
if (typeof permalink === "boolean") {
|
||
debugDev("Using boolean permalink %o", permalink);
|
||
permalinkValue = permalink;
|
||
} else if (permalink && (!this.config.dynamicPermalinks || data.dynamicPermalink === false)) {
|
||
debugDev("Not using dynamic permalinks, using %o", permalink);
|
||
permalinkValue = permalink;
|
||
} else if (isPlainObject(permalink)) {
|
||
// Empty permalink {} object should act as if no permalink was set at all
|
||
// and inherit the default behavior
|
||
let isEmptyObject = Object.keys(permalink).length === 0;
|
||
if (!isEmptyObject) {
|
||
let promises = [];
|
||
let keys = [];
|
||
for (let key in permalink) {
|
||
keys.push(key);
|
||
if (key !== "build" && Array.isArray(permalink[key])) {
|
||
promises.push(
|
||
Promise.all([...permalink[key]].map((entry) => super.renderPermalink(entry, data))),
|
||
);
|
||
} else {
|
||
promises.push(super.renderPermalink(permalink[key], data));
|
||
}
|
||
}
|
||
|
||
let results = await Promise.all(promises);
|
||
|
||
permalinkValue = {};
|
||
for (let j = 0, k = keys.length; j < k; j++) {
|
||
let key = keys[j];
|
||
permalinkValue[key] = results[j];
|
||
debug(
|
||
"Rendering permalink.%o for %o: %s becomes %o",
|
||
key,
|
||
this.inputPath,
|
||
permalink[key],
|
||
results[j],
|
||
);
|
||
}
|
||
}
|
||
} else if (permalink) {
|
||
// render variables inside permalink front matter, bypass markdown
|
||
permalinkValue = await super.renderPermalink(permalink, data);
|
||
debug("Rendering permalink for %o: %s becomes %o", this.inputPath, permalink, permalinkValue);
|
||
debugDev("Permalink rendered with data: %o", data);
|
||
}
|
||
|
||
// Override default permalink behavior. Only do this if permalink was _not_ in the data cascade
|
||
if (!permalink && this.config.dynamicPermalinks && data.dynamicPermalink !== false) {
|
||
let tr = await this.getTemplateRender();
|
||
let permalinkCompilation = tr.engine.permalinkNeedsCompilation("");
|
||
if (typeof permalinkCompilation === "function") {
|
||
let ret = await this._renderFunction(permalinkCompilation, permalinkValue, this.inputPath);
|
||
if (ret !== undefined) {
|
||
if (typeof ret === "function") {
|
||
// function
|
||
permalinkValue = await this._renderFunction(ret, data);
|
||
} else {
|
||
// scalar
|
||
permalinkValue = ret;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (permalinkValue !== undefined) {
|
||
return this._getRawPermalinkInstance(permalinkValue);
|
||
}
|
||
|
||
// No `permalink` specified in data cascade, do the default
|
||
let p = TemplatePermalink.generate(
|
||
this.getTemplateSubfolder(),
|
||
this.baseFile,
|
||
this.extraOutputSubdirectory,
|
||
this.engine.defaultTemplateFileExtension,
|
||
);
|
||
p.setUrlTransforms(this.config.urlTransforms);
|
||
return p;
|
||
}
|
||
|
||
async usePermalinkRoot() {
|
||
// @cachedproperty
|
||
if (this._usePermalinkRoot === undefined) {
|
||
// TODO this only works with immediate front matter and not data files
|
||
let { data } = await this.getFrontMatterData();
|
||
this._usePermalinkRoot = data[this.config.keys.permalinkRoot];
|
||
}
|
||
|
||
return this._usePermalinkRoot;
|
||
}
|
||
|
||
async getOutputLocations(data) {
|
||
this.bench.get("(count) getOutputLocations").incrementCount();
|
||
let link = await this._getLink(data);
|
||
|
||
let path;
|
||
if (await this.usePermalinkRoot()) {
|
||
path = link.toPathFromRoot();
|
||
} else {
|
||
path = link.toPath(this.outputDir);
|
||
}
|
||
|
||
return {
|
||
linkInstance: link,
|
||
rawPath: link.toOutputPath(),
|
||
href: link.toHref(),
|
||
path: path,
|
||
};
|
||
}
|
||
|
||
// This is likely now a test-only method
|
||
// Preferred to use the singular `getOutputLocations` above.
|
||
async getRawOutputPath(data) {
|
||
this.bench.get("(count) getRawOutputPath").incrementCount();
|
||
let link = await this._getLink(data);
|
||
return link.toOutputPath();
|
||
}
|
||
|
||
// Preferred to use the singular `getOutputLocations` above.
|
||
async getOutputHref(data) {
|
||
this.bench.get("(count) getOutputHref").incrementCount();
|
||
let link = await this._getLink(data);
|
||
return link.toHref();
|
||
}
|
||
|
||
// Preferred to use the singular `getOutputLocations` above.
|
||
async getOutputPath(data) {
|
||
this.bench.get("(count) getOutputPath").incrementCount();
|
||
let link = await this._getLink(data);
|
||
if (await this.usePermalinkRoot()) {
|
||
return link.toPathFromRoot();
|
||
}
|
||
return link.toPath(this.outputDir);
|
||
}
|
||
|
||
async _testGetAllLayoutFrontMatterData() {
|
||
let { data: frontMatterData } = await this.getFrontMatterData();
|
||
|
||
if (frontMatterData[this.config.keys.layout]) {
|
||
let layout = this.getLayout(frontMatterData[this.config.keys.layout]);
|
||
return await layout.getData();
|
||
}
|
||
return {};
|
||
}
|
||
|
||
async #getData() {
|
||
debugDev("%o getData", this.inputPath);
|
||
let localData = {};
|
||
let globalData = {};
|
||
|
||
if (this.templateData) {
|
||
localData = await this.templateData.getTemplateDirectoryData(this.inputPath);
|
||
globalData = await this.templateData.getGlobalData(this.inputPath);
|
||
debugDev("%o getData getTemplateDirectoryData and getGlobalData", this.inputPath);
|
||
}
|
||
|
||
let { data: frontMatterData } = await this.getFrontMatterData();
|
||
|
||
let mergedLayoutData = {};
|
||
let tr = await this.getTemplateRender();
|
||
if (tr.engine.useLayouts()) {
|
||
let layoutKey =
|
||
frontMatterData[this.config.keys.layout] ||
|
||
localData[this.config.keys.layout] ||
|
||
globalData[this.config.keys.layout];
|
||
|
||
// Layout front matter data
|
||
if (layoutKey) {
|
||
let layout = this.getLayout(layoutKey);
|
||
|
||
mergedLayoutData = await layout.getData();
|
||
debugDev("%o getData merged layout chain front matter", this.inputPath);
|
||
}
|
||
}
|
||
|
||
try {
|
||
let mergedData = TemplateData.mergeDeep(
|
||
this.config.dataDeepMerge,
|
||
{},
|
||
globalData,
|
||
mergedLayoutData,
|
||
localData,
|
||
frontMatterData,
|
||
);
|
||
|
||
if (this.config.freezeReservedData) {
|
||
ReservedData.check(mergedData);
|
||
}
|
||
|
||
await this.addPage(mergedData);
|
||
|
||
debugDev("%o getData mergedData", this.inputPath);
|
||
|
||
return mergedData;
|
||
} catch (e) {
|
||
if (
|
||
ReservedData.isReservedDataError(e) ||
|
||
(e instanceof TypeError &&
|
||
e.message.startsWith("Cannot add property") &&
|
||
e.message.endsWith("not extensible"))
|
||
) {
|
||
throw new EleventyBaseError(
|
||
`You attempted to set one of Eleventy’s reserved data property names${e.reservedNames ? `: ${e.reservedNames.join(", ")}` : ""}. You can opt-out of this behavior with \`eleventyConfig.setFreezeReservedData(false)\` or rename/remove the property in your data cascade that conflicts with Eleventy’s reserved property names (e.g. \`eleventy\`, \`pkg\`, and others). Learn more: https://v3.11ty.dev/docs/data-eleventy-supplied/`,
|
||
e,
|
||
);
|
||
}
|
||
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async getData() {
|
||
if (!this._dataCache) {
|
||
// @cachedproperty
|
||
this._dataCache = this.#getData();
|
||
}
|
||
|
||
return this._dataCache;
|
||
}
|
||
|
||
async addPage(data) {
|
||
if (!("page" in data)) {
|
||
data.page = {};
|
||
}
|
||
|
||
// Make sure to keep these keys synchronized in src/Util/ReservedData.js
|
||
data.page.inputPath = this.inputPath;
|
||
data.page.fileSlug = this.fileSlugStr;
|
||
data.page.filePathStem = this.filePathStem;
|
||
data.page.outputFileExtension = this.engine.defaultTemplateFileExtension;
|
||
data.page.templateSyntax = this.templateRender.getEnginesList(
|
||
data[this.config.keys.engineOverride],
|
||
);
|
||
|
||
let newDate = await this.getMappedDate(data);
|
||
// Skip date assignment if custom date is falsy.
|
||
if (newDate) {
|
||
data.page.date = newDate;
|
||
}
|
||
|
||
// data.page.url
|
||
// data.page.outputPath
|
||
// data.page.excerpt from gray-matter and Front Matter
|
||
// data.page.lang from I18nPlugin
|
||
}
|
||
|
||
// Tests only
|
||
async render() {
|
||
throw new Error("Internal error: `Template->render` was removed in Eleventy 3.0.");
|
||
}
|
||
|
||
// Tests only
|
||
async renderLayout() {
|
||
throw new Error("Internal error: `Template->renderLayout` was removed in Eleventy 3.0.");
|
||
}
|
||
|
||
async renderDirect(str, data, bypassMarkdown) {
|
||
return super.render(str, data, bypassMarkdown);
|
||
}
|
||
|
||
// This is the primary render mechanism, called via TemplateMap->populateContentDataInMap
|
||
async renderPageEntryWithoutLayout(pageEntry) {
|
||
// @cachedproperty
|
||
if (!this._cacheRenderedPromise) {
|
||
this._cacheRenderedPromise = this.renderDirect(pageEntry.rawInput, pageEntry.data);
|
||
this.renderCount++;
|
||
}
|
||
|
||
return this._cacheRenderedPromise;
|
||
}
|
||
|
||
setLinters(linters) {
|
||
if (!isPlainObject(linters)) {
|
||
throw new Error("Object expected in setLinters");
|
||
}
|
||
// this acts as a reset
|
||
this.linters = [];
|
||
for (let linter of Object.values(linters).filter((l) => typeof l === "function")) {
|
||
this.addLinter(linter);
|
||
}
|
||
}
|
||
|
||
addLinter(callback) {
|
||
this.linters.push(callback);
|
||
}
|
||
|
||
async runLinters(str, page) {
|
||
let { inputPath, outputPath, url } = page;
|
||
let pageData = page.data.page;
|
||
|
||
for (let linter of this.linters) {
|
||
// these can be asynchronous but no guarantee of order when they run
|
||
linter.call(
|
||
{
|
||
inputPath,
|
||
outputPath,
|
||
url,
|
||
page: pageData,
|
||
},
|
||
str,
|
||
inputPath,
|
||
outputPath,
|
||
);
|
||
}
|
||
}
|
||
|
||
setTransforms(transforms) {
|
||
if (!isPlainObject(transforms)) {
|
||
throw new Error("Object expected in setTransforms");
|
||
}
|
||
this.transforms = transforms;
|
||
}
|
||
|
||
async runTransforms(str, pageEntry) {
|
||
return TransformsUtil.runAll(str, pageEntry.data.page, this.transforms, {
|
||
logger: this.logger,
|
||
});
|
||
}
|
||
|
||
async #renderComputedUnit(entry, data) {
|
||
if (typeof entry === "string") {
|
||
return this.renderComputedData(entry, data);
|
||
}
|
||
|
||
if (isPlainObject(entry)) {
|
||
for (let key in entry) {
|
||
entry[key] = await this.#renderComputedUnit(entry[key], data);
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(entry)) {
|
||
for (let j = 0, k = entry.length; j < k; j++) {
|
||
entry[j] = await this.#renderComputedUnit(entry[j], data);
|
||
}
|
||
}
|
||
|
||
return entry;
|
||
}
|
||
|
||
_addComputedEntry(computedData, obj, parentKey, declaredDependencies) {
|
||
// this check must come before isPlainObject
|
||
if (typeof obj === "function") {
|
||
computedData.add(parentKey, obj, declaredDependencies);
|
||
} else if (Array.isArray(obj) || typeof obj === "string") {
|
||
// Arrays are treated as one entry in the dependency graph now, Issue #3728
|
||
computedData.addTemplateString(
|
||
parentKey,
|
||
async function (innerData) {
|
||
return this.tmpl.#renderComputedUnit(obj, innerData);
|
||
},
|
||
declaredDependencies,
|
||
this.getParseForSymbolsFunction(obj),
|
||
this,
|
||
);
|
||
} else if (isPlainObject(obj)) {
|
||
// Arrays used to be computed here
|
||
for (let key in obj) {
|
||
let keys = [];
|
||
if (parentKey) {
|
||
keys.push(parentKey);
|
||
}
|
||
keys.push(key);
|
||
this._addComputedEntry(computedData, obj[key], keys.join("."), declaredDependencies);
|
||
}
|
||
} else {
|
||
// Numbers, booleans, etc
|
||
computedData.add(parentKey, obj, declaredDependencies);
|
||
}
|
||
}
|
||
|
||
async addComputedData(data) {
|
||
if (isPlainObject(data?.[this.config.keys.computed])) {
|
||
this.computedData = new ComputedData(this.config);
|
||
|
||
// Note that `permalink` is only a thing that gets consumed—it does not go directly into generated data
|
||
// this allows computed entries to use page.url or page.outputPath and they’ll be resolved properly
|
||
|
||
// TODO Room for optimization here—we don’t need to recalculate `getOutputHref` and `getOutputPath`
|
||
// TODO Why are these using addTemplateString instead of add
|
||
this.computedData.addTemplateString(
|
||
"page.url",
|
||
async function (data) {
|
||
return this.tmpl.getOutputHref(data);
|
||
},
|
||
data.permalink ? ["permalink"] : undefined,
|
||
false, // skip symbol resolution
|
||
this,
|
||
);
|
||
|
||
this.computedData.addTemplateString(
|
||
"page.outputPath",
|
||
async function (data) {
|
||
return this.tmpl.getOutputPath(data);
|
||
},
|
||
data.permalink ? ["permalink"] : undefined,
|
||
false, // skip symbol resolution
|
||
this,
|
||
);
|
||
|
||
// Check for reserved properties in computed data
|
||
if (this.config.freezeReservedData) {
|
||
ReservedData.check(data[this.config.keys.computed]);
|
||
}
|
||
|
||
// actually add the computed data
|
||
this._addComputedEntry(this.computedData, data[this.config.keys.computed]);
|
||
|
||
// limited run of computed data—save the stuff that relies on collections for later.
|
||
debug("First round of computed data for %o", this.inputPath);
|
||
await this.computedData.setupData(data, function (entry) {
|
||
return !this.isUsesStartsWith(entry, "collections.");
|
||
|
||
// TODO possible improvement here is to only process page.url, page.outputPath, permalink
|
||
// instead of only punting on things that rely on collections.
|
||
// let firstPhaseComputedData = ["page.url", "page.outputPath", ...this.getOrderFor("page.url"), ...this.getOrderFor("page.outputPath")];
|
||
// return firstPhaseComputedData.indexOf(entry) > -1;
|
||
});
|
||
} else {
|
||
if (!("page" in data)) {
|
||
data.page = {};
|
||
}
|
||
|
||
// pagination will already have these set via Pagination->getPageTemplates
|
||
if (data.page.url && data.page.outputPath) {
|
||
return;
|
||
}
|
||
|
||
let { href, path } = await this.getOutputLocations(data);
|
||
data.page.url = href;
|
||
data.page.outputPath = path;
|
||
}
|
||
}
|
||
|
||
// Computed data consuming collections!
|
||
async resolveRemainingComputedData(data) {
|
||
// If it doesn’t exist, computed data is not used for this template
|
||
if (this.computedData) {
|
||
debug("Second round of computed data for %o", this.inputPath);
|
||
return this.computedData.processRemainingData(data);
|
||
}
|
||
}
|
||
|
||
static augmentWithTemplateContentProperty(obj) {
|
||
return Object.defineProperties(obj, {
|
||
needsCheck: {
|
||
enumerable: false,
|
||
writable: true,
|
||
value: true,
|
||
},
|
||
_templateContent: {
|
||
enumerable: false,
|
||
writable: true,
|
||
value: undefined,
|
||
},
|
||
templateContent: {
|
||
enumerable: true,
|
||
set(content) {
|
||
if (content === undefined) {
|
||
this.needsCheck = false;
|
||
}
|
||
this._templateContent = content;
|
||
},
|
||
get() {
|
||
if (this.needsCheck && this._templateContent === undefined) {
|
||
if (this.template.isRenderable()) {
|
||
// should at least warn here
|
||
throw new TemplateContentPrematureUseError(
|
||
`Tried to use templateContent too early on ${this.inputPath}${
|
||
this.pageNumber ? ` (page ${this.pageNumber})` : ""
|
||
}`,
|
||
);
|
||
} else {
|
||
throw new TemplateContentUnrenderedTemplateError(
|
||
`Tried to use templateContent on unrendered template: ${
|
||
this.inputPath
|
||
}${this.pageNumber ? ` (page ${this.pageNumber})` : ""}`,
|
||
);
|
||
}
|
||
}
|
||
return this._templateContent;
|
||
},
|
||
},
|
||
// Alias for templateContent for consistency
|
||
content: {
|
||
enumerable: true,
|
||
get() {
|
||
return this.templateContent;
|
||
},
|
||
set() {
|
||
throw new Error("Setter not available for `content`. Use `templateContent` instead.");
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
static async runPreprocessors(inputPath, content, data, preprocessors) {
|
||
let skippedVia = false;
|
||
for (let [name, preprocessor] of Object.entries(preprocessors)) {
|
||
let { filter, callback } = preprocessor;
|
||
|
||
let filters;
|
||
if (Array.isArray(filter)) {
|
||
filters = filter;
|
||
} else if (typeof filter === "string") {
|
||
filters = filter.split(",");
|
||
} else {
|
||
throw new Error(
|
||
`Expected file extensions passed to "${name}" content preprocessor to be a string or array. Received: ${filter}`,
|
||
);
|
||
}
|
||
|
||
filters = filters.map((extension) => {
|
||
if (extension.startsWith(".") || extension === "*") {
|
||
return extension;
|
||
}
|
||
|
||
return `.${extension}`;
|
||
});
|
||
|
||
if (!filters.some((extension) => extension === "*" || inputPath.endsWith(extension))) {
|
||
// skip
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
let ret = await callback.call(
|
||
{
|
||
inputPath,
|
||
},
|
||
data,
|
||
content,
|
||
);
|
||
|
||
// Returning explicit false is the same as ignoring the template
|
||
if (ret === false) {
|
||
skippedVia = name;
|
||
continue;
|
||
}
|
||
|
||
// Different from transforms: returning falsy (not false) here does nothing (skips the preprocessor)
|
||
if (ret) {
|
||
content = ret;
|
||
}
|
||
} catch (e) {
|
||
throw new EleventyBaseError(
|
||
`Preprocessor \`${name}\` encountered an error when transforming ${inputPath}.`,
|
||
e,
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
skippedVia,
|
||
content,
|
||
};
|
||
}
|
||
|
||
async getTemplates(data) {
|
||
let content = await this.getPreRender();
|
||
let { skippedVia, content: rawInput } = await Template.runPreprocessors(
|
||
this.inputPath,
|
||
content,
|
||
data,
|
||
this.config.preprocessors,
|
||
);
|
||
|
||
if (skippedVia) {
|
||
debug(
|
||
"Skipping %o, the %o preprocessor returned an explicit `false`",
|
||
this.inputPath,
|
||
skippedVia,
|
||
);
|
||
return [];
|
||
}
|
||
|
||
// Raw Input *includes* preprocessor modifications
|
||
// https://github.com/11ty/eleventy/issues/1206
|
||
data.page.rawInput = rawInput;
|
||
|
||
if (!Pagination.hasPagination(data)) {
|
||
await this.addComputedData(data);
|
||
|
||
let obj = {
|
||
template: this, // not on the docs but folks are relying on it
|
||
rawInput,
|
||
groupNumber: 0, // i18n plugin
|
||
data,
|
||
|
||
page: data.page,
|
||
inputPath: this.inputPath,
|
||
fileSlug: this.fileSlugStr,
|
||
filePathStem: this.filePathStem,
|
||
date: data.page.date,
|
||
outputPath: data.page.outputPath,
|
||
url: data.page.url,
|
||
};
|
||
|
||
obj = Template.augmentWithTemplateContentProperty(obj);
|
||
|
||
return [obj];
|
||
} else {
|
||
// needs collections for pagination items
|
||
// but individual pagination entries won’t be part of a collection
|
||
this.paging = new Pagination(this, data, this.config);
|
||
|
||
let pageTemplates = await this.paging.getPageTemplates();
|
||
let objects = [];
|
||
|
||
for (let pageEntry of pageTemplates) {
|
||
await pageEntry.template.addComputedData(pageEntry.data);
|
||
|
||
let obj = {
|
||
template: pageEntry.template, // not on the docs but folks are relying on it
|
||
rawInput,
|
||
pageNumber: pageEntry.pageNumber,
|
||
groupNumber: pageEntry.groupNumber || 0,
|
||
|
||
data: pageEntry.data,
|
||
|
||
inputPath: this.inputPath,
|
||
fileSlug: this.fileSlugStr,
|
||
filePathStem: this.filePathStem,
|
||
|
||
page: pageEntry.data.page,
|
||
date: pageEntry.data.page.date,
|
||
outputPath: pageEntry.data.page.outputPath,
|
||
url: pageEntry.data.page.url,
|
||
};
|
||
|
||
obj = Template.augmentWithTemplateContentProperty(obj);
|
||
|
||
objects.push(obj);
|
||
}
|
||
|
||
return objects;
|
||
}
|
||
}
|
||
|
||
async _write({ url, outputPath, data, rawInput }, finalContent) {
|
||
let lang = {
|
||
start: "Writing",
|
||
finished: "written",
|
||
};
|
||
|
||
if (!this.isDryRun) {
|
||
if (this.logger.isLoggingEnabled()) {
|
||
let isVirtual = this.isVirtualTemplate();
|
||
let tr = await this.getTemplateRender();
|
||
let engineList = tr.getReadableEnginesListDifferingFromFileExtension();
|
||
let suffix = `${isVirtual ? " (virtual)" : ""}${engineList ? ` (${engineList})` : ""}`;
|
||
this.logger.log(
|
||
`${lang.start} ${outputPath} ${chalk.gray(`from ${this.inputPath}${suffix}`)}`,
|
||
);
|
||
}
|
||
} else if (this.isDryRun) {
|
||
return;
|
||
}
|
||
|
||
let templateBenchmarkDir = this.bench.get("Template make parent directory");
|
||
templateBenchmarkDir.before();
|
||
|
||
if (this.eleventyConfig.templateHandling?.writeMode === "async") {
|
||
await this.fsManager.createDirectoryForFile(outputPath);
|
||
} else {
|
||
this.fsManager.createDirectoryForFileSync(outputPath);
|
||
}
|
||
|
||
templateBenchmarkDir.after();
|
||
|
||
if (!Buffer.isBuffer(finalContent) && typeof finalContent !== "string") {
|
||
throw new Error(
|
||
`The return value from the render function for the ${this.engine.name} template was not a String or Buffer. Received ${finalContent}`,
|
||
);
|
||
}
|
||
|
||
let templateBenchmark = this.bench.get("Template Write");
|
||
templateBenchmark.before();
|
||
|
||
if (this.eleventyConfig.templateHandling?.writeMode === "async") {
|
||
await this.fsManager.writeFile(outputPath, finalContent);
|
||
} else {
|
||
this.fsManager.writeFileSync(outputPath, finalContent);
|
||
}
|
||
|
||
templateBenchmark.after();
|
||
this.writeCount++;
|
||
debug(`${outputPath} ${lang.finished}.`);
|
||
|
||
let ret = {
|
||
inputPath: this.inputPath,
|
||
outputPath: outputPath,
|
||
url,
|
||
content: finalContent,
|
||
rawInput,
|
||
};
|
||
|
||
if (data && this.config.dataFilterSelectors?.size > 0) {
|
||
ret.data = this.retrieveDataForJsonOutput(data, this.config.dataFilterSelectors);
|
||
}
|
||
|
||
return ret;
|
||
}
|
||
|
||
async #renderPageEntryWithLayoutsAndTransforms(pageEntry) {
|
||
let content;
|
||
let layoutKey = pageEntry.data[this.config.keys.layout];
|
||
if (this.engine.useLayouts() && layoutKey) {
|
||
let layout = pageEntry.template.getLayout(layoutKey);
|
||
content = await layout.renderPageEntry(pageEntry);
|
||
} else {
|
||
content = pageEntry.templateContent;
|
||
}
|
||
|
||
await this.runLinters(content, pageEntry);
|
||
|
||
content = await this.runTransforms(content, pageEntry);
|
||
return content;
|
||
}
|
||
|
||
async renderPageEntry(pageEntry) {
|
||
// @cachedproperty
|
||
if (!pageEntry.template._cacheRenderedTransformsAndLayoutsPromise) {
|
||
pageEntry.template._cacheRenderedTransformsAndLayoutsPromise =
|
||
this.#renderPageEntryWithLayoutsAndTransforms(pageEntry);
|
||
}
|
||
|
||
return pageEntry.template._cacheRenderedTransformsAndLayoutsPromise;
|
||
}
|
||
|
||
retrieveDataForJsonOutput(data, selectors) {
|
||
let filtered = {};
|
||
for (let selector of selectors) {
|
||
let value = lodashGet(data, selector);
|
||
lodashSet(filtered, selector, value);
|
||
}
|
||
return filtered;
|
||
}
|
||
|
||
async generateMapEntry(mapEntry, to) {
|
||
let ret = [];
|
||
|
||
for (let page of mapEntry._pages) {
|
||
let content;
|
||
|
||
// Note that behavior.render is overridden when using json or ndjson output
|
||
if (page.template.isRenderable()) {
|
||
// this reuses page.templateContent, it doesn’t render it
|
||
content = await page.template.renderPageEntry(page);
|
||
}
|
||
|
||
if (to === "json" || to === "ndjson") {
|
||
let obj = {
|
||
url: page.url,
|
||
inputPath: page.inputPath,
|
||
outputPath: page.outputPath,
|
||
rawInput: page.rawInput,
|
||
content: content,
|
||
};
|
||
|
||
if (this.config.dataFilterSelectors?.size > 0) {
|
||
obj.data = this.retrieveDataForJsonOutput(page.data, this.config.dataFilterSelectors);
|
||
}
|
||
|
||
if (to === "ndjson") {
|
||
let jsonString = JSON.stringify(obj);
|
||
this.logger.toStream(jsonString + os.EOL);
|
||
continue;
|
||
}
|
||
|
||
// json
|
||
ret.push(obj);
|
||
continue;
|
||
}
|
||
|
||
if (!page.template.isRenderable()) {
|
||
debug("Template not written %o from %o.", page.outputPath, page.template.inputPath);
|
||
continue;
|
||
}
|
||
|
||
if (!page.template.behavior.isWriteable()) {
|
||
debug(
|
||
"Template not written %o from %o (via permalink: false, permalink.build: false, or a permalink object without a build property).",
|
||
page.outputPath,
|
||
page.template.inputPath,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// compile returned undefined
|
||
if (content !== undefined) {
|
||
ret.push(this._write(page, content));
|
||
}
|
||
}
|
||
|
||
return Promise.all(ret);
|
||
}
|
||
|
||
async clone() {
|
||
// TODO do we need to even run the constructor here or can we simplify it even more
|
||
let tmpl = new Template(
|
||
this.inputPath,
|
||
this.templateData,
|
||
this.extensionMap,
|
||
this.eleventyConfig,
|
||
);
|
||
|
||
// We use this cheap property setter below instead
|
||
// await tmpl.getTemplateRender();
|
||
|
||
// preserves caches too, e.g. _frontMatterDataCache
|
||
// Does not yet include .computedData
|
||
for (let key in this) {
|
||
tmpl[key] = this[key];
|
||
}
|
||
|
||
return tmpl;
|
||
}
|
||
|
||
getWriteCount() {
|
||
return this.writeCount;
|
||
}
|
||
|
||
getRenderCount() {
|
||
return this.renderCount;
|
||
}
|
||
|
||
async getInputFileStat() {
|
||
// @cachedproperty
|
||
if (!this._stats) {
|
||
this._stats = fsStat(this.inputPath);
|
||
}
|
||
|
||
return this._stats;
|
||
}
|
||
|
||
async _getDateInstance(key = "birthtimeMs") {
|
||
let stat = await this.getInputFileStat();
|
||
|
||
// Issue 1823: https://github.com/11ty/eleventy/issues/1823
|
||
// return current Date in a Lambda
|
||
// otherwise ctime would be "1980-01-01T00:00:00.000Z"
|
||
// otherwise birthtime would be "1970-01-01T00:00:00.000Z"
|
||
if (stat.birthtimeMs === 0) {
|
||
return new Date();
|
||
}
|
||
|
||
let newDate = new Date(stat[key]);
|
||
|
||
debug(
|
||
"Template date: using file’s %o for %o of %o (from %o)",
|
||
key,
|
||
this.inputPath,
|
||
newDate,
|
||
stat.birthtimeMs,
|
||
);
|
||
|
||
return newDate;
|
||
}
|
||
|
||
async getMappedDate(data) {
|
||
let dateValue = data?.date;
|
||
|
||
// These can return a Date object, or a string.
|
||
// Already type checked to be functions in UserConfig
|
||
for (let fn of this.config.customDateParsing) {
|
||
let ret = fn.call(
|
||
{
|
||
page: data.page,
|
||
},
|
||
dateValue,
|
||
);
|
||
|
||
if (ret) {
|
||
debug("getMappedDate: date value override via `addDateParsing` callback to %o", ret);
|
||
dateValue = ret;
|
||
}
|
||
}
|
||
|
||
if (dateValue) {
|
||
debug("getMappedDate: using a date in the data for %o of %o", this.inputPath, data.date);
|
||
if (dateValue?.constructor?.name === "DateTime") {
|
||
// YAML does its own date parsing
|
||
debug("getMappedDate: found DateTime instance: %o", dateValue);
|
||
return dateValue.toJSDate();
|
||
}
|
||
|
||
if (dateValue instanceof Date) {
|
||
// YAML does its own date parsing
|
||
debug("getMappedDate: found Date instance (maybe from YAML): %o", dateValue);
|
||
return dateValue;
|
||
}
|
||
|
||
if (typeof dateValue !== "string") {
|
||
throw new Error(
|
||
`Data cascade value for \`date\` (${dateValue}) is invalid for ${this.inputPath}. Expected a JavaScript Date instance, luxon DateTime instance, or String value.`,
|
||
);
|
||
}
|
||
|
||
// special strings
|
||
if (!this.isVirtualTemplate()) {
|
||
if (dateValue.toLowerCase() === "git last modified") {
|
||
let d = await getDateFromGitLastUpdated(this.inputPath);
|
||
if (d) {
|
||
return d;
|
||
}
|
||
|
||
// return now if this file is not yet available in `git`
|
||
return new Date();
|
||
}
|
||
if (dateValue.toLowerCase() === "last modified") {
|
||
return this._getDateInstance("ctimeMs");
|
||
}
|
||
if (dateValue.toLowerCase() === "git created") {
|
||
let d = await getDateFromGitFirstAdded(this.inputPath);
|
||
if (d) {
|
||
return d;
|
||
}
|
||
|
||
// return now if this file is not yet available in `git`
|
||
return new Date();
|
||
}
|
||
if (dateValue.toLowerCase() === "created") {
|
||
return this._getDateInstance("birthtimeMs");
|
||
}
|
||
}
|
||
|
||
// try to parse with Luxon
|
||
let date = DateTime.fromISO(dateValue, { zone: "utc" });
|
||
if (!date.isValid) {
|
||
throw new Error(
|
||
`Data cascade value for \`date\` (${dateValue}) is invalid for ${this.inputPath}`,
|
||
);
|
||
}
|
||
debug("getMappedDate: Luxon parsed %o: %o and %o", dateValue, date, date.toJSDate());
|
||
|
||
return date.toJSDate();
|
||
}
|
||
|
||
// No Date supplied in the Data Cascade, try to find the date in the file name
|
||
let filepathRegex = this.inputPath.match(/(\d{4}-\d{2}-\d{2})/);
|
||
if (filepathRegex !== null) {
|
||
// if multiple are found in the path, use the first one for the date
|
||
let dateObj = DateTime.fromISO(filepathRegex[1], {
|
||
zone: "utc",
|
||
}).toJSDate();
|
||
debug(
|
||
"getMappedDate: using filename regex time for %o of %o: %o",
|
||
this.inputPath,
|
||
filepathRegex[1],
|
||
dateObj,
|
||
);
|
||
return dateObj;
|
||
}
|
||
|
||
// No Date supplied in the Data Cascade
|
||
if (this.isVirtualTemplate()) {
|
||
return new Date();
|
||
}
|
||
|
||
return this._getDateInstance("birthtimeMs");
|
||
}
|
||
|
||
// Important reminder: Template data is first generated in TemplateMap
|
||
async getTemplateMapEntries(data) {
|
||
debugDev("%o getMapped()", this.inputPath);
|
||
|
||
this.behavior.setRenderViaDataCascade(data);
|
||
|
||
let entries = [];
|
||
// does not return outputPath or url, we don’t want to render permalinks yet
|
||
entries.push({
|
||
template: this,
|
||
inputPath: this.inputPath,
|
||
data,
|
||
});
|
||
|
||
return entries;
|
||
}
|
||
}
|
||
|
||
export default Template;
|