Files
firefrost-website/node_modules/@11ty/eleventy/src/TemplateMap.js
2026-04-02 18:39:00 -05:00

685 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isPlainObject, TemplatePath } from "@11ty/eleventy-utils";
import debugUtil from "debug";
import TemplateCollection from "./TemplateCollection.js";
import EleventyErrorUtil from "./Errors/EleventyErrorUtil.js";
import UsingCircularTemplateContentReferenceError from "./Errors/UsingCircularTemplateContentReferenceError.js";
import EleventyBaseError from "./Errors/EleventyBaseError.js";
import DuplicatePermalinkOutputError from "./Errors/DuplicatePermalinkOutputError.js";
import TemplateData from "./Data/TemplateData.js";
import GlobalDependencyMap from "./GlobalDependencyMap.js";
const debug = debugUtil("Eleventy:TemplateMap");
class EleventyMapPagesError extends EleventyBaseError {}
class EleventyDataSchemaError extends EleventyBaseError {}
// These template URL filenames are allowed to exclude file extensions
const EXTENSIONLESS_URL_ALLOWLIST = [
"/_redirects", // Netlify specific
"/.htaccess", // Apache
"/_headers", // Cloudflare
];
// must match TemplateDepGraph
const SPECIAL_COLLECTION_NAMES = {
keys: "[keys]",
all: "all",
};
class TemplateMap {
#dependencyMapInitialized = false;
constructor(eleventyConfig) {
if (!eleventyConfig || eleventyConfig.constructor.name !== "TemplateConfig") {
throw new Error("Missing or invalid `eleventyConfig` argument.");
}
this.eleventyConfig = eleventyConfig;
this.map = [];
this.collectionsData = null;
this.cached = false;
this.verboseOutput = true;
this.collection = new TemplateCollection();
}
set userConfig(config) {
this._userConfig = config;
}
get userConfig() {
if (!this._userConfig) {
// TODO use this.config for this, need to add collections to mergeable props in userconfig
this._userConfig = this.eleventyConfig.userConfig;
}
return this._userConfig;
}
get config() {
if (!this._config) {
this._config = this.eleventyConfig.getConfig();
}
return this._config;
}
async add(template) {
if (!template) {
return;
}
let data = await template.getData();
let entries = await template.getTemplateMapEntries(data);
for (let map of entries) {
this.map.push(map);
}
}
getMap() {
return this.map;
}
getTagTarget(str) {
if (str === "collections") {
// special, means targeting `collections` specifically
return SPECIAL_COLLECTION_NAMES.keys;
}
if (str.startsWith("collections.")) {
return str.slice("collections.".length);
}
// Fixes #2851
if (str.startsWith("collections['") || str.startsWith('collections["')) {
return str.slice("collections['".length, -2);
}
}
getPaginationTagTarget(entry) {
if (entry.data.pagination?.data) {
return this.getTagTarget(entry.data.pagination.data);
}
}
#addEntryToGlobalDependencyGraph(entry) {
let consumes = [];
consumes.push(this.getPaginationTagTarget(entry));
if (Array.isArray(entry.data.eleventyImport?.collections)) {
for (let tag of entry.data.eleventyImport.collections) {
consumes.push(tag);
}
}
// Important: consumers must come before publishers
// TODO itd be nice to set the dependency relationship for addCollection here
// But collections are not yet populated (they populate after template order)
let publishes = TemplateData.getIncludedCollectionNames(entry.data);
this.config.uses.addNewNodeRelationships(entry.inputPath, consumes, publishes);
}
addAllToGlobalDependencyGraph() {
this.#dependencyMapInitialized = true;
// Should come before individual entry additions
this.config.uses.initializeUserConfigurationApiCollections();
for (let entry of this.map) {
this.#addEntryToGlobalDependencyGraph(entry);
}
}
async setCollectionByTagName(tagName) {
if (this.isUserConfigCollectionName(tagName)) {
// async
this.collectionsData[tagName] = await this.getUserConfigCollection(tagName);
} else {
this.collectionsData[tagName] = this.getTaggedCollection(tagName);
}
let precompiled = this.config.precompiledCollections;
if (precompiled?.[tagName]) {
if (
tagName === "all" ||
!Array.isArray(this.collectionsData[tagName]) ||
this.collectionsData[tagName].length === 0
) {
this.collectionsData[tagName] = precompiled[tagName];
}
}
}
// TODO(slightlyoff): major bottleneck
async initDependencyMap(fullTemplateOrder) {
// Temporary workaround for async constructor work in templates
// Issue #3170 #3870
let inputPathSet = new Set(fullTemplateOrder);
await Promise.all(
this.map
.filter(({ inputPath }) => {
return inputPathSet.has(inputPath);
})
.map(({ template }) => {
// This also happens for layouts in TemplateContent->compile
return template.asyncTemplateInitialization();
}),
);
for (let depEntry of fullTemplateOrder) {
if (GlobalDependencyMap.isCollection(depEntry)) {
let tagName = GlobalDependencyMap.getTagName(depEntry);
// [keys] should initialize `all`
if (tagName === SPECIAL_COLLECTION_NAMES.keys) {
await this.setCollectionByTagName("all");
// [NAME] is special and implied (e.g. [keys])
} else if (!tagName.startsWith("[") && !tagName.endsWith("]")) {
// is a tag (collection) entry
await this.setCollectionByTagName(tagName);
}
continue;
}
// is a template entry
let map = this.getMapEntryForInputPath(depEntry);
await this.#initDependencyMapEntry(map);
}
}
async #initDependencyMapEntry(map) {
try {
map._pages = await map.template.getTemplates(map.data);
} catch (e) {
throw new EleventyMapPagesError(
"Error generating template page(s) for " + map.inputPath + ".",
e,
);
}
if (map._pages.length === 0) {
// Reminder: a serverless code path was removed here.
} else {
let counter = 0;
for (let page of map._pages) {
// Copy outputPath to map entry
// This is no longer used internally, just for backwards compatibility
// Error added in v3 for https://github.com/11ty/eleventy/issues/3183
if (map.data.pagination) {
if (!Object.prototype.hasOwnProperty.call(map, "outputPath")) {
Object.defineProperty(map, "outputPath", {
get() {
throw new Error(
"Internal error: `.outputPath` on a paginated map entry is not consistent. Use `_pages[…].outputPath` instead.",
);
},
});
}
} else if (!map.outputPath) {
map.outputPath = page.outputPath;
}
if (counter === 0 || map.data.pagination?.addAllPagesToCollections) {
if (map.data.eleventyExcludeFromCollections !== true) {
// is in *some* collections
this.collection.add(page);
}
}
counter++;
}
}
}
getTemplateOrder() {
// 1. Templates that dont use Pagination
// 2. Pagination templates that consume config API collections
// 3. Pagination templates consuming `collections`
// 4. Pagination templates consuming `collections.all`
let fullTemplateOrder = this.config.uses.getTemplateOrder();
return fullTemplateOrder
.map((entry) => {
if (GlobalDependencyMap.isCollection(entry)) {
return entry;
}
let inputPath = TemplatePath.addLeadingDotSlash(entry);
if (!this.hasMapEntryForInputPath(inputPath)) {
return false;
}
return inputPath;
})
.filter(Boolean);
}
async cache() {
if (!this.#dependencyMapInitialized) {
this.addAllToGlobalDependencyGraph();
}
this.collectionsData = {};
for (let entry of this.map) {
entry.data.collections = this.collectionsData;
}
let fullTemplateOrder = this.getTemplateOrder();
debug(
"Rendering templates in order (%o concurrency): %O",
this.userConfig.getConcurrency(),
fullTemplateOrder,
);
await this.initDependencyMap(fullTemplateOrder);
await this.resolveRemainingComputedData();
let orderedPaths = this.#removeTagsFromTemplateOrder(fullTemplateOrder);
let orderedMap = orderedPaths.map((inputPath) => {
return this.getMapEntryForInputPath(inputPath);
});
await this.config.events.emitLazy("eleventy.contentMap", () => {
return {
inputPathToUrl: this.generateInputUrlContentMap(orderedMap),
urlToInputPath: this.generateUrlMap(orderedMap),
};
});
await this.runDataSchemas(orderedMap);
await this.populateContentDataInMap(orderedMap);
this.populateCollectionsWithContent();
this.cached = true;
this.checkForDuplicatePermalinks();
this.checkForMissingFileExtensions();
await this.config.events.emitLazy("eleventy.layouts", () => this.generateLayoutsMap());
}
generateInputUrlContentMap(orderedMap) {
let entries = {};
for (let entry of orderedMap) {
entries[entry.inputPath] = entry._pages.map((entry) => entry.url);
}
return entries;
}
generateUrlMap(orderedMap) {
let entries = {};
for (let entry of orderedMap) {
for (let page of entry._pages) {
// duplicate urls throw an error, so we can return non array here
entries[page.url] = {
inputPath: entry.inputPath,
groupNumber: page.groupNumber,
};
}
}
return entries;
}
hasMapEntryForInputPath(inputPath) {
return Boolean(this.getMapEntryForInputPath(inputPath));
}
// TODO(slightlyoff): hot inner loop?
getMapEntryForInputPath(inputPath) {
let absoluteInputPath = TemplatePath.absolutePath(inputPath);
return this.map.find((entry) => {
if (entry.inputPath === inputPath || entry.inputPath === absoluteInputPath) {
return entry;
}
});
}
#removeTagsFromTemplateOrder(maps) {
return maps.filter((dep) => !GlobalDependencyMap.isCollection(dep));
}
async runDataSchemas(orderedMap) {
for (let map of orderedMap) {
if (!map._pages) {
continue;
}
for (let pageEntry of map._pages) {
// Data Schema callback #879
if (typeof pageEntry.data[this.config.keys.dataSchema] === "function") {
try {
await pageEntry.data[this.config.keys.dataSchema](pageEntry.data);
} catch (e) {
throw new EleventyDataSchemaError(
`Error in the data schema for: ${map.inputPath} (via \`eleventyDataSchema\`)`,
e,
);
}
}
}
}
}
async populateContentDataInMap(orderedMap) {
let usedTemplateContentTooEarlyMap = [];
// Note that empty pagination templates will be skipped here as not renderable
let filteredMap = orderedMap.filter((entry) => entry.template.isRenderable());
// Get concurrency level from user config
const concurrency = this.userConfig.getConcurrency();
// Process the templates in chunks to limit concurrency
// This replaces the functionality of p-map's concurrency option
for (let i = 0; i < filteredMap.length; i += concurrency) {
// Create a chunk of tasks that will run in parallel
const chunk = filteredMap.slice(i, i + concurrency);
// Run the chunk of tasks in parallel
await Promise.all(
chunk.map(async (map) => {
if (!map._pages) {
throw new Error(`Internal error: _pages not found for ${map.inputPath}`);
}
// IMPORTANT: this is where template content is rendered
try {
for (let pageEntry of map._pages) {
pageEntry.templateContent =
await pageEntry.template.renderPageEntryWithoutLayout(pageEntry);
}
} catch (e) {
if (EleventyErrorUtil.isPrematureTemplateContentError(e)) {
// Add to list of templates that need to be processed again
usedTemplateContentTooEarlyMap.push(map);
// Reset cached render promise
for (let pageEntry of map._pages) {
pageEntry.template.resetCaches({ render: true });
}
} else {
throw e;
}
}
}),
);
}
// Process templates that had premature template content errors
// This is the second pass for templates that couldn't be rendered in the first pass
for (let map of usedTemplateContentTooEarlyMap) {
try {
for (let pageEntry of map._pages) {
pageEntry.templateContent =
await pageEntry.template.renderPageEntryWithoutLayout(pageEntry);
}
} catch (e) {
if (EleventyErrorUtil.isPrematureTemplateContentError(e)) {
// If we still have template content errors after the second pass,
// it's likely a circular reference
throw new UsingCircularTemplateContentReferenceError(
`${map.inputPath} contains a circular reference (using collections) to its own templateContent.`,
);
} else {
// rethrow?
throw e;
}
}
}
}
getTaggedCollection(tag) {
let result;
if (!tag || tag === "all") {
result = this.collection.getAllSorted();
} else {
result = this.collection.getFilteredByTag(tag);
}
// May not return an array (can be anything)
// https://www.11ty.dev/docs/collections-api/#return-values
debug(`Collection: collections.${tag || "all"} size: ${result?.length}`);
return result;
}
/* 3.0.0-alpha.1: setUserConfigCollections method removed (was only used for testing) */
isUserConfigCollectionName(name) {
let collections = this.userConfig.getCollections();
return name && !!collections[name];
}
getUserConfigCollectionNames() {
return Object.keys(this.userConfig.getCollections());
}
async getUserConfigCollection(name) {
let configCollections = this.userConfig.getCollections();
// This works with async now
let result = await configCollections[name](this.collection);
// May not return an array (can be anything)
// https://www.11ty.dev/docs/collections-api/#return-values
debug(`Collection: collections.${name} size: ${result?.length}`);
return result;
}
populateCollectionsWithContent() {
for (let collectionName in this.collectionsData) {
// skip custom collections set in configuration files that have arbitrary types
if (!Array.isArray(this.collectionsData[collectionName])) {
continue;
}
for (let item of this.collectionsData[collectionName]) {
// skip custom collections set in configuration files that have arbitrary types
if (!isPlainObject(item) || !("inputPath" in item)) {
continue;
}
let entry = this.getMapEntryForInputPath(item.inputPath);
// This check skips precompiled collections
if (entry) {
let index = item.pageNumber || 0;
let content = entry._pages[index]._templateContent;
if (content !== undefined) {
item.templateContent = content;
}
}
}
}
}
async resolveRemainingComputedData() {
let promises = [];
for (let entry of this.map) {
for (let pageEntry of entry._pages) {
if (this.config.keys.computed in pageEntry.data) {
promises.push(pageEntry.template.resolveRemainingComputedData(pageEntry.data));
}
}
}
return Promise.all(promises);
}
async generateLayoutsMap() {
let layouts = {};
for (let entry of this.map) {
for (let page of entry._pages) {
let tmpl = page.template;
if (tmpl.templateUsesLayouts(page.data)) {
let layoutKey = page.data[this.config.keys.layout];
let layout = tmpl.getLayout(layoutKey);
let layoutChain = await layout.getLayoutChain();
let priors = [];
for (let filepath of layoutChain) {
if (!layouts[filepath]) {
layouts[filepath] = new Set();
}
layouts[filepath].add(page.inputPath);
for (let prior of priors) {
layouts[filepath].add(prior);
}
priors.push(filepath);
}
}
}
}
for (let key in layouts) {
layouts[key] = Array.from(layouts[key]);
}
return layouts;
}
#onEachPage(callback) {
for (let template of this.map) {
for (let page of template._pages) {
callback(page, template);
}
}
}
checkForDuplicatePermalinks() {
let inputs = {};
let outputPaths = {};
let warnings = {};
this.#onEachPage((page, template) => {
if (page.outputPath === false || page.url === false) {
// do nothing (also serverless)
} else {
// Make sure output doesnt overwrite input (e.g. --input=. --output=.)
// Related to https://github.com/11ty/eleventy/issues/3327
if (page.outputPath === page.inputPath) {
throw new DuplicatePermalinkOutputError(
`The template at "${page.inputPath}" attempted to overwrite itself.`,
);
} else if (inputs[page.outputPath]) {
throw new DuplicatePermalinkOutputError(
`The template at "${page.inputPath}" attempted to overwrite an existing template at "${page.outputPath}".`,
);
}
inputs[page.inputPath] = true;
if (!outputPaths[page.outputPath]) {
outputPaths[page.outputPath] = [template.inputPath];
} else {
warnings[page.outputPath] = `Output conflict: multiple input files are writing to \`${
page.outputPath
}\`. Use distinct \`permalink\` values to resolve this conflict.
1. ${template.inputPath}
${outputPaths[page.outputPath]
.map(function (inputPath, index) {
return ` ${index + 2}. ${inputPath}\n`;
})
.join("")}
`;
outputPaths[page.outputPath].push(template.inputPath);
}
}
});
let warningList = Object.values(warnings);
if (warningList.length) {
// throw one at a time
throw new DuplicatePermalinkOutputError(warningList[0]);
}
}
checkForMissingFileExtensions() {
// disabled in config
if (this.userConfig?.errorReporting?.allowMissingExtensions === true) {
return;
}
this.#onEachPage((page) => {
if (
page.outputPath === false ||
page.url === false ||
page.data.eleventyAllowMissingExtension ||
EXTENSIONLESS_URL_ALLOWLIST.some((url) => page.url.endsWith(url))
) {
// do nothing (also serverless)
} else {
if (TemplatePath.getExtension(page.outputPath) === "") {
let e =
new Error(`The template at '${page.inputPath}' attempted to write to '${page.outputPath}'${page.data.permalink ? ` (via \`permalink\` value: '${page.data.permalink}')` : ""}, which is a target on the file system that does not include a file extension.
You *probably* want to add a file extension to your permalink so that hosts will know how to correctly serve this file to web browsers. Without a file extension, this file may not be reliably deployed without additional hosting configuration (it wont have a mime type) and may also cause local development issues if you later attempt to write to a subdirectory of the same name.
Learn more: https://v3.11ty.dev/docs/permalinks/#trailing-slashes
This is usually but not *always* an error so if youd like to disable this error message, add \`eleventyAllowMissingExtension: true\` somewhere in the data cascade for this template or use \`eleventyConfig.configureErrorReporting({ allowMissingExtensions: true });\` to disable this feature globally.`);
e.skipOriginalStack = true;
throw e;
}
}
});
}
// TODO move these into TemplateMapTest.js
_testGetAllTags() {
let allTags = {};
for (let map of this.map) {
let tags = map.data.tags;
if (Array.isArray(tags)) {
for (let tag of tags) {
allTags[tag] = true;
}
}
}
return Object.keys(allTags);
}
async _testGetUserConfigCollectionsData() {
let collections = {};
let configCollections = this.userConfig.getCollections();
for (let name in configCollections) {
collections[name] = configCollections[name](this.collection);
debug(`Collection: collections.${name} size: ${collections[name].length}`);
}
return collections;
}
async _testGetTaggedCollectionsData() {
let collections = {};
collections.all = this.collection.getAllSorted();
debug(`Collection: collections.all size: ${collections.all.length}`);
let tags = this._testGetAllTags();
for (let tag of tags) {
collections[tag] = this.collection.getFilteredByTag(tag);
debug(`Collection: collections.${tag} size: ${collections[tag].length}`);
}
return collections;
}
async _testGetAllCollectionsData() {
let collections = {};
let taggedCollections = await this._testGetTaggedCollectionsData();
Object.assign(collections, taggedCollections);
let userConfigCollections = await this._testGetUserConfigCollectionsData();
Object.assign(collections, userConfigCollections);
return collections;
}
async _testGetCollectionsData() {
if (!this.cached) {
await this.cache();
}
return this.collectionsData;
}
}
export default TemplateMap;