import debugUtil from "debug"; import { TemplatePath } from "@11ty/eleventy-utils"; import JavaScriptDependencies from "./Util/JavaScriptDependencies.js"; import PathNormalizer from "./Util/PathNormalizer.js"; import { TemplateDepGraph } from "./Util/TemplateDepGraph.js"; const debug = debugUtil("Eleventy:Dependencies"); class GlobalDependencyMap { // dependency-graph requires these keys to be alphabetic strings static LAYOUT_KEY = "layout"; static COLLECTION_PREFIX = "__collection:"; // must match TemplateDepGraph key #map; #templateConfig; #cachedUserConfigurationCollectionApiNames; static isCollection(entry) { return entry.startsWith(this.COLLECTION_PREFIX); } static getTagName(entry) { if (this.isCollection(entry)) { return entry.slice(this.COLLECTION_PREFIX.length); } } static getCollectionKeyForEntry(entry) { return `${GlobalDependencyMap.COLLECTION_PREFIX}${entry}`; } reset() { this.#map = undefined; } setIsEsm(isEsm) { this.isEsm = isEsm; } setTemplateConfig(templateConfig) { this.#templateConfig = templateConfig; // These have leading dot slashes, but so do the paths from Eleventy this.#templateConfig.userConfig.events.once("eleventy.layouts", async (layouts) => { await this.addLayoutsToMap(layouts); }); } get userConfigurationCollectionApiNames() { if (this.#cachedUserConfigurationCollectionApiNames) { return this.#cachedUserConfigurationCollectionApiNames; } return Object.keys(this.#templateConfig.userConfig.getCollections()) || []; } initializeUserConfigurationApiCollections() { this.addCollectionApiNames(this.userConfigurationCollectionApiNames); } // For Testing setCollectionApiNames(names = []) { this.#cachedUserConfigurationCollectionApiNames = names; } addCollectionApiNames(names = []) { if (!names || names.length === 0) { return; } for (let collectionName of names) { this.map.addConfigCollectionName(collectionName); } } filterOutLayouts(nodes = []) { return nodes.filter((node) => { if (GlobalDependencyMap.isLayoutNode(this.map, node)) { return false; } return true; }); } filterOutCollections(nodes = []) { return nodes.filter((node) => !node.startsWith(GlobalDependencyMap.COLLECTION_PREFIX)); } static removeLayoutNodes(graph, nodeList) { return nodeList.filter((node) => { if (this.isLayoutNode(graph, node)) { return false; } return true; }); } removeLayoutNodes(normalizedLayouts) { let nodes = this.map.overallOrder(); for (let node of nodes) { if (!GlobalDependencyMap.isLayoutNode(this.map, node)) { continue; } // previous layout is not in the new layout map (no templates are using it) if (!normalizedLayouts[node]) { this.map.removeNode(node); } // important: if the layout map changed to have different templates (but was not removed) // this is already handled by `resetNode` called via TemplateMap } } // Eleventy Layouts don’t show up in the dependency graph, so we handle those separately async addLayoutsToMap(layouts) { let normalizedLayouts = this.normalizeLayoutsObject(layouts); // Clear out any previous layout relationships to make way for the new ones this.removeLayoutNodes(normalizedLayouts); for (let layout in normalizedLayouts) { // We add this pre-emptively to add the `layout` data if (!this.map.hasNode(layout)) { this.map.addNode(layout, { type: GlobalDependencyMap.LAYOUT_KEY, }); } else { this.map.setNodeData(layout, { type: GlobalDependencyMap.LAYOUT_KEY, }); } // Potential improvement: only add the first template in the chain for a template and manage any upstream layouts by their own relationships for (let pageTemplate of normalizedLayouts[layout]) { this.addDependency(pageTemplate, [layout]); } if (this.#templateConfig?.shouldSpiderJavaScriptDependencies()) { let deps = await JavaScriptDependencies.getDependencies([layout], this.isEsm); this.addDependency(layout, deps); } } } get map() { if (!this.#map) { // this.#map = new DepGraph({ circular: true }); this.#map = new TemplateDepGraph(); } return this.#map; } set map(graph) { this.#map = graph; } normalizeNode(node) { if (!node) { return; } // TODO tests for this // Fix URL objects passed in (sass does this) if (typeof node !== "string" && "toString" in node) { node = node.toString(); } if (typeof node !== "string") { throw new Error("`addDependencies` files must be strings. Received:" + node); } return PathNormalizer.fullNormalization(node); } normalizeLayoutsObject(layouts) { let o = {}; for (let rawLayout in layouts) { let layout = this.normalizeNode(rawLayout); o[layout] = layouts[rawLayout].map((entry) => this.normalizeNode(entry)); } return o; } getDependantsFor(node) { if (!node) { return []; } node = this.normalizeNode(node); if (!this.map.hasNode(node)) { return []; } // Direct dependants and dependencies, both publish and consume from collections return this.map.directDependantsOf(node); } hasNode(node) { return this.map.hasNode(this.normalizeNode(node)); } findCollectionsRemovedFrom(node, collectionNames) { if (!this.hasNode(node)) { return new Set(); } let prevDeps = this.getDependantsFor(node) .map((entry) => GlobalDependencyMap.getTagName(entry)) .filter(Boolean); let prevDepsSet = new Set(prevDeps); let deleted = new Set(); for (let dep of prevDepsSet) { if (!collectionNames.has(dep)) { deleted.add(dep); } } return deleted; } resetNode(node) { node = this.normalizeNode(node); if (!this.map.hasNode(node)) { return; } // We don’t want to remove relationships that consume this, controlled by the upstream content // for (let dep of this.map.directDependantsOf(node)) { // this.map.removeDependency(dep, node); // } for (let dep of this.map.directDependenciesOf(node)) { this.map.removeDependency(node, dep); } } getTemplatesThatConsumeCollections(collectionNames) { let templates = new Set(); for (let name of collectionNames) { let collectionKey = GlobalDependencyMap.getCollectionKeyForEntry(name); if (!this.map.hasNode(collectionKey)) { continue; } for (let node of this.map.dependantsOf(collectionKey)) { if (!node.startsWith(GlobalDependencyMap.COLLECTION_PREFIX)) { if (!GlobalDependencyMap.isLayoutNode(this.map, node)) { templates.add(node); } } } } return templates; } static isLayoutNode(graph, node) { if (!graph.hasNode(node)) { return false; } return graph.getNodeData(node)?.type === GlobalDependencyMap.LAYOUT_KEY; } getLayoutsUsedBy(node) { node = this.normalizeNode(node); if (!this.map.hasNode(node)) { return []; } let layouts = []; // include self, if layout if (GlobalDependencyMap.isLayoutNode(this.map, node)) { layouts.push(node); } this.map.dependantsOf(node).forEach((node) => { // we only want layouts if (GlobalDependencyMap.isLayoutNode(this.map, node)) { return layouts.push(node); } }); return layouts; } // In order // Does not include original templatePaths (unless *they* are second-order relevant) getTemplatesRelevantToTemplateList(templatePaths) { let overallOrder = this.map.overallOrder(); overallOrder = this.filterOutLayouts(overallOrder); overallOrder = this.filterOutCollections(overallOrder); let relevantLookup = {}; for (let inputPath of templatePaths) { inputPath = TemplatePath.stripLeadingDotSlash(inputPath); let deps = this.getDependencies(inputPath, false); if (Array.isArray(deps)) { let paths = this.filterOutCollections(deps); for (let node of paths) { relevantLookup[node] = true; } } } return overallOrder.filter((node) => { if (relevantLookup[node]) { return true; } return false; }); } // Layouts are not relevant to compile cache and can be ignored getDependencies(node, includeLayouts = true) { node = this.normalizeNode(node); // `false` means the Node was unknown if (!this.map.hasNode(node)) { return false; } if (includeLayouts) { return this.map.dependenciesOf(node).filter(Boolean); } return GlobalDependencyMap.removeLayoutNodes(this.map, this.map.dependenciesOf(node)); } #addNode(name) { if (this.map.hasNode(name)) { return; } this.map.addNode(name); } // node arguments are already normalized #addDependency(from, toArray = []) { this.#addNode(from); // only if not already added if (!Array.isArray(toArray)) { throw new Error("Second argument to `addDependency` must be an Array."); } // debug("%o depends on %o", from, toArray); for (let to of toArray) { this.#addNode(to); // only if not already added if (from !== to) { this.map.addDependency(from, to); } } } addDependency(from, toArray = []) { this.#addDependency( this.normalizeNode(from), toArray.map((to) => this.normalizeNode(to)), ); } addNewNodeRelationships(from, consumes = [], publishes = []) { consumes = consumes.filter(Boolean); publishes = publishes.filter(Boolean); debug("%o consumes %o and publishes to %o", from, consumes, publishes); from = this.normalizeNode(from); this.map.addTemplate(from, consumes, publishes); } // Layouts are not relevant to compile cache and can be ignored hasDependency(from, to, includeLayouts) { to = this.normalizeNode(to); let deps = this.getDependencies(from, includeLayouts); // normalizes `from` if (!deps) { return false; } return deps.includes(to); } // Layouts are not relevant to compile cache and can be ignored isFileRelevantTo(fullTemplateInputPath, comparisonFile, includeLayouts) { fullTemplateInputPath = this.normalizeNode(fullTemplateInputPath); comparisonFile = this.normalizeNode(comparisonFile); // No watch/serve changed file if (!comparisonFile) { return false; } // The file that changed is the relevant file if (fullTemplateInputPath === comparisonFile) { return true; } // The file that changed is a dependency of the template // comparisonFile is used by fullTemplateInputPath if (this.hasDependency(fullTemplateInputPath, comparisonFile, includeLayouts)) { return true; } return false; } isFileUsedBy(parent, child, includeLayouts) { if (this.hasDependency(parent, child, includeLayouts)) { // child is used by parent return true; } return false; } getTemplateOrder() { let order = []; for (let entry of this.map.overallOrder()) { order.push(entry); } return order; } stringify() { return JSON.stringify(this.map, function replacer(key, value) { // Serialize internal Map objects. if (value instanceof Map) { let obj = {}; for (let [k, v] of value) { obj[k] = v; } return obj; } return value; }); } restore(persisted) { let obj = JSON.parse(persisted); let graph = new TemplateDepGraph(); // https://github.com/jriecken/dependency-graph/issues/44 // Restore top level serialized Map objects (in stringify above) for (let key in obj) { let map = graph[key]; for (let k in obj[key]) { let v = obj[key][k]; map.set(k, v); } } this.map = graph; } } export default GlobalDependencyMap;