Files
homepage/node_modules/@11ty/eleventy/src/TemplateMap.js
2024-11-03 17:16:20 +01:00

829 lines
23 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 { DepGraph as DependencyGraph } from "dependency-graph";
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";
const debug = debugUtil("Eleventy:TemplateMap");
const debugDev = debugUtil("Dev:Eleventy:TemplateMap");
class TemplateMapConfigError 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
];
class TemplateMap {
constructor(eleventyConfig) {
if (!eleventyConfig) {
throw new TemplateMapConfigError("Missing config 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;
}
static get tagPrefix() {
return "___TAG___";
}
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.startsWith("collections.")) {
return str.slice("collections.".length);
}
// Fixes #2851
if (str.startsWith("collections['") || str.startsWith('collections["')) {
return str.slice("collections['".length, -2);
}
}
/* ---
* pagination:
* data: collections
* ---
*/
isPaginationOverAllCollections(entry) {
if (entry.data.pagination?.data) {
return (
entry.data.pagination.data === "collections" ||
entry.data.pagination.data === "collections.all"
);
}
}
getPaginationTagTarget(entry) {
if (entry.data.pagination?.data) {
return this.getTagTarget(entry.data.pagination.data);
}
}
addTagsToGraph(graph, inputPath, tags) {
if (!Array.isArray(tags)) {
return;
}
for (let tag of tags) {
let tagWithPrefix = TemplateMap.tagPrefix + tag;
if (!graph.hasNode(tagWithPrefix)) {
graph.addNode(tagWithPrefix);
}
// Populates to collections.tagName
// Dependency from tag to inputPath
graph.addDependency(tagWithPrefix, inputPath);
}
}
addDeclaredDependenciesToGraph(graph, inputPath, deps) {
if (!Array.isArray(deps)) {
return;
}
for (let tag of deps) {
let tagWithPrefix = TemplateMap.tagPrefix + tag;
if (!graph.hasNode(tagWithPrefix)) {
graph.addNode(tagWithPrefix);
}
// Dependency from inputPath to collection/tag
graph.addDependency(inputPath, tagWithPrefix);
}
}
// Exclude: Pagination templates consuming `collections` or `collections.all`
// Exclude: Pagination templates that consume config API collections
// Include: Pagination templates that dont consume config API collections
// Include: Templates that dont use Pagination
getMappedDependencies() {
let graph = new DependencyGraph();
let tagPrefix = TemplateMap.tagPrefix;
graph.addNode(tagPrefix + "all");
for (let entry of this.map) {
if (this.isPaginationOverAllCollections(entry)) {
continue;
}
// using Pagination (but not targeting a user config collection)
let paginationTagTarget = this.getPaginationTagTarget(entry);
if (paginationTagTarget) {
if (this.isUserConfigCollectionName(paginationTagTarget)) {
// delay this one to the second stage
continue;
} else {
// using pagination but over a tagged collection
graph.addNode(entry.inputPath);
if (!graph.hasNode(tagPrefix + paginationTagTarget)) {
graph.addNode(tagPrefix + paginationTagTarget);
}
graph.addDependency(entry.inputPath, tagPrefix + paginationTagTarget);
}
} else {
// not using pagination
graph.addNode(entry.inputPath);
}
let collections = TemplateData.getIncludedCollectionNames(entry.data);
this.addTagsToGraph(graph, entry.inputPath, collections);
this.addDeclaredDependenciesToGraph(
graph,
entry.inputPath,
entry.data.eleventyImport?.collections,
);
}
return graph;
}
// Exclude: Pagination templates consuming `collections` or `collections.all`
// Include: Pagination templates that consume config API collections
getDelayedMappedDependencies() {
let graph = new DependencyGraph();
let tagPrefix = TemplateMap.tagPrefix;
graph.addNode(tagPrefix + "all");
let userConfigCollections = this.getUserConfigCollectionNames();
// Add tags from named user config collections
for (let tag of userConfigCollections) {
graph.addNode(tagPrefix + tag);
}
for (let entry of this.map) {
if (this.isPaginationOverAllCollections(entry)) {
continue;
}
let paginationTagTarget = this.getPaginationTagTarget(entry);
if (paginationTagTarget && this.isUserConfigCollectionName(paginationTagTarget)) {
if (!graph.hasNode(entry.inputPath)) {
graph.addNode(entry.inputPath);
}
graph.addDependency(entry.inputPath, tagPrefix + paginationTagTarget);
let collections = TemplateData.getIncludedCollectionNames(entry.data);
this.addTagsToGraph(graph, entry.inputPath, collections);
this.addDeclaredDependenciesToGraph(
graph,
entry.inputPath,
entry.data.eleventyImport?.collections,
);
}
}
return graph;
}
// Exclude: Pagination templates consuming `collections.all`
// Include: Pagination templates consuming `collections`
getPaginatedOverCollectionsMappedDependencies() {
let graph = new DependencyGraph();
let tagPrefix = TemplateMap.tagPrefix;
let allNodeAdded = false;
for (let entry of this.map) {
if (this.isPaginationOverAllCollections(entry) && !this.getPaginationTagTarget(entry)) {
if (!allNodeAdded) {
graph.addNode(tagPrefix + "all");
allNodeAdded = true;
}
if (!graph.hasNode(entry.inputPath)) {
graph.addNode(entry.inputPath);
}
let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
if (collectionNames.includes("all")) {
// collections.all
graph.addDependency(tagPrefix + "all", entry.inputPath);
// Note that `tags` are otherwise ignored here
}
this.addDeclaredDependenciesToGraph(
graph,
entry.inputPath,
entry.data.eleventyImport?.collections,
);
}
}
return graph;
}
// Include: Pagination templates consuming `collections.all`
getPaginatedOverAllCollectionMappedDependencies() {
let graph = new DependencyGraph();
let tagPrefix = TemplateMap.tagPrefix;
let allNodeAdded = false;
for (let entry of this.map) {
if (
this.isPaginationOverAllCollections(entry) &&
this.getPaginationTagTarget(entry) === "all"
) {
if (!allNodeAdded) {
graph.addNode(tagPrefix + "all");
allNodeAdded = true;
}
if (!graph.hasNode(entry.inputPath)) {
graph.addNode(entry.inputPath);
}
let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
if (collectionNames.includes("all")) {
// Populates into collections.all
// This is circular!
graph.addDependency(tagPrefix + "all", entry.inputPath);
// Note that `tags` are otherwise ignored here
}
this.addDeclaredDependenciesToGraph(
graph,
entry.inputPath,
entry.data.eleventyImport?.collections,
);
}
}
return graph;
}
getTemplateMapDependencyGraph() {
return [
this.getMappedDependencies(),
this.getDelayedMappedDependencies(),
this.getPaginatedOverCollectionsMappedDependencies(),
this.getPaginatedOverAllCollectionMappedDependencies(),
];
}
getFullTemplateMapOrder() {
// convert dependency graphs to ordered arrays
return this.getTemplateMapDependencyGraph().map((entry) => entry.overallOrder());
}
#addEntryToGlobalDependencyGraph(entry) {
let paginationTagTarget = this.getPaginationTagTarget(entry);
if (paginationTagTarget) {
this.config.uses.addDependencyConsumesCollection(entry.inputPath, paginationTagTarget);
}
let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
for (let name of collectionNames) {
this.config.uses.addDependencyPublishesToCollection(entry.inputPath, name);
}
if (Array.isArray(entry.data.eleventyImport?.collections)) {
for (let tag of entry.data.eleventyImport.collections) {
this.config.uses.addDependencyConsumesCollection(entry.inputPath, tag);
}
}
}
addAllToGlobalDependencyGraph() {
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(dependencyMap) {
let tagPrefix = TemplateMap.tagPrefix;
for (let depEntry of dependencyMap) {
if (depEntry.startsWith(tagPrefix)) {
// is a tag (collection) entry
let tagName = depEntry.slice(tagPrefix.length);
await this.setCollectionByTagName(tagName);
} else {
// is a template entry
let map = this.getMapEntryForInputPath(depEntry);
map._pages = await map.template.getTemplates(map.data);
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++;
}
}
}
}
}
async cache() {
debug("Caching collections objects.");
this.collectionsData = {};
for (let entry of this.map) {
entry.data.collections = this.collectionsData;
}
let [dependencyMap, delayedDependencyMap, firstPaginatedDepMap, secondPaginatedDepMap] =
this.getFullTemplateMapOrder();
await this.initDependencyMap(dependencyMap);
await this.initDependencyMap(delayedDependencyMap);
await this.initDependencyMap(firstPaginatedDepMap);
await this.initDependencyMap(secondPaginatedDepMap);
await this.resolveRemainingComputedData();
let orderedPaths = this.getOrderedInputPaths(
dependencyMap,
delayedDependencyMap,
firstPaginatedDepMap,
secondPaginatedDepMap,
);
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;
}
// TODO(slightlyoff): hot inner loop?
getMapEntryForInputPath(inputPath) {
for (let map of this.map) {
if (map.inputPath === inputPath) {
return map;
}
}
}
// Filter out any tag nodes
getOrderedInputPaths(...maps) {
let orderedMap = [];
let tagPrefix = TemplateMap.tagPrefix;
for (let map of maps) {
for (let dep of map) {
if (!dep.startsWith(tagPrefix)) {
orderedMap.push(dep);
}
}
}
return orderedMap;
}
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());
for (let map of filteredMap) {
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)) {
usedTemplateContentTooEarlyMap.push(map);
// Reset cached render promise
for (let pageEntry of map._pages) {
pageEntry.template.resetCaches({ render: true });
}
} else {
throw e;
}
}
debugDev("Added this.map[...].templateContent, outputPath, et al for one map entry");
}
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)) {
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);
}
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);
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(await 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;
let layoutKey = page.data[this.config.keys.layout];
if (layoutKey) {
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 permalinks = {};
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 (!permalinks[page.outputPath]) {
permalinks[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}
${permalinks[page.outputPath]
.map(function (inputPath, index) {
return ` ${index + 2}. ${inputPath}\n`;
})
.join("")}
`;
permalinks[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;