import fs from "node:fs"; import chalk from "kleur"; import { Merge, TemplatePath, isPlainObject } from "@11ty/eleventy-utils"; import debugUtil from "debug"; import { EleventyImportRaw, EleventyImportRawFromEleventy } from "./Util/Require.js"; import EleventyBaseError from "./Errors/EleventyBaseError.js"; import UserConfig from "./UserConfig.js"; import GlobalDependencyMap from "./GlobalDependencyMap.js"; import ExistsCache from "./Util/ExistsCache.js"; import eventBus from "./EventBus.js"; import ProjectTemplateFormats from "./Util/ProjectTemplateFormats.js"; const debug = debugUtil("Eleventy:TemplateConfig"); const debugDev = debugUtil("Dev:Eleventy:TemplateConfig"); /** * @module 11ty/eleventy/TemplateConfig */ /** * Config as used by the template. * @typedef {object} module:11ty/eleventy/TemplateConfig~TemplateConfig~config * @property {String} [pathPrefix] - The path prefix. */ /** * Errors in eleventy config. * @ignore */ class EleventyConfigError extends EleventyBaseError {} /** * Errors in eleventy plugins. * @ignore */ class EleventyPluginError extends EleventyBaseError {} /** * Config for a template. * @ignore * @param {{}} customRootConfig - tbd. * @param {String} projectConfigPath - Path to local project config. */ class TemplateConfig { #templateFormats; #runMode; #configManuallyDefined = false; /** @type {UserConfig} */ #userConfig = new UserConfig(); constructor(customRootConfig, projectConfigPath) { /** @type {object} */ this.overrides = {}; /** * @type {String} * @description Path to local project config. * @default .eleventy.js */ if (projectConfigPath !== undefined) { this.#configManuallyDefined = true; if (!projectConfigPath) { // falsy skips config files this.projectConfigPaths = []; } else { this.projectConfigPaths = [projectConfigPath]; } } else { this.projectConfigPaths = [ ".eleventy.js", "eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs", ]; } if (customRootConfig) { /** * @type {object} * @description Custom root config. */ this.customRootConfig = customRootConfig; debug("Warning: Using custom root config!"); } else { this.customRootConfig = null; } this.hasConfigMerged = false; this.isEsm = false; } get userConfig() { return this.#userConfig; } get aggregateBenchmark() { return this.userConfig.benchmarks.aggregate; } /* Setter for Logger */ setLogger(logger) { this.logger = logger; this.userConfig.logger = this.logger; } /* Setter for Directories instance */ setDirectories(directories) { this.directories = directories; this.userConfig.directories = directories.getUserspaceInstance(); } /* Setter for TemplateFormats instance */ setTemplateFormats(templateFormats) { this.#templateFormats = templateFormats; } get templateFormats() { if (!this.#templateFormats) { this.#templateFormats = new ProjectTemplateFormats(); } return this.#templateFormats; } /* Backwards compat */ get inputDir() { return this.directories.input; } setRunMode(runMode) { this.#runMode = runMode; } shouldSpiderJavaScriptDependencies() { // not for a standard build return ( (this.#runMode === "watch" || this.#runMode === "serve") && this.userConfig.watchJavaScriptDependencies ); } /** * Normalises local project config file path. * * @method * @returns {String|undefined} - The normalised local project config file path. */ getLocalProjectConfigFile() { let configFiles = this.getLocalProjectConfigFiles(); // Add the configFiles[0] in case of a test, where no file exists on the file system let configFile = configFiles.find((path) => path && fs.existsSync(path)) || configFiles[0]; if (configFile) { return configFile; } } getLocalProjectConfigFiles() { if (this.projectConfigPaths?.length > 0) { return TemplatePath.addLeadingDotSlashArray(this.projectConfigPaths.filter((path) => path)); } return []; } setProjectUsingEsm(isEsmProject) { this.isEsm = !!isEsmProject; this.usesGraph.setIsEsm(isEsmProject); } getIsProjectUsingEsm() { return this.isEsm; } /** * Resets the configuration. */ async reset() { debugDev("Resetting configuration: TemplateConfig and UserConfig."); this.userConfig.reset(); // await this.initializeRootConfig(); await this.forceReloadConfig(); this.usesGraph.reset(); // Clear the compile cache eventBus.emit("eleventy.compileCacheReset"); } /** * Resets the configuration while in watch mode. * * @todo Add implementation. */ resetOnWatch() { // nothing yet } hasInitialized() { return this.hasConfigMerged; } /** * Async-friendly init method */ async init(overrides) { await this.initializeRootConfig(); if (overrides) { this.appendToRootConfig(overrides); } this.config = await this.mergeConfig(); this.hasConfigMerged = true; } /** * Force a reload of the configuration object. */ async forceReloadConfig() { this.hasConfigMerged = false; await this.init(); } /** * Returns the config object. * * @returns {{}} - The config object. */ getConfig() { if (!this.hasConfigMerged) { throw new Error("Invalid call to .getConfig(). Needs an .init() first."); } return this.config; } /** * Overwrites the config path. * * @param {String} path - The new config path. */ async setProjectConfigPath(path) { this.#configManuallyDefined = true; if (path !== undefined) { this.projectConfigPaths = [path]; } else { this.projectConfigPaths = []; } if (this.hasConfigMerged) { // merge it again debugDev("Merging in getConfig again after setting the local project config path."); await this.forceReloadConfig(); } } /** * Overwrites the path prefix. * * @param {String} pathPrefix - The new path prefix. */ setPathPrefix(pathPrefix) { if (pathPrefix && pathPrefix !== "/") { debug("Setting pathPrefix to %o", pathPrefix); this.overrides.pathPrefix = pathPrefix; } } /** * Gets the current path prefix denoting the root folder the output will be deployed to * * @returns {String} - The path prefix string */ getPathPrefix() { if (this.overrides.pathPrefix) { return this.overrides.pathPrefix; } if (!this.hasConfigMerged) { throw new Error("Config has not yet merged. Needs `init()`."); } return this.config?.pathPrefix; } /** * Bootstraps the config object. */ async initializeRootConfig() { this.rootConfig = this.customRootConfig; if (!this.rootConfig) { let { default: cfg } = await EleventyImportRawFromEleventy("./src/defaultConfig.js"); this.rootConfig = cfg; } if (typeof this.rootConfig === "function") { // Not yet using async in defaultConfig.js this.rootConfig = this.rootConfig.call(this, this.userConfig); } debug("Default Eleventy config %o", this.rootConfig); } /* * Add additional overrides to the root config object, used for testing * * @param {object} - a subset of the return Object from the user’s config file. */ appendToRootConfig(obj) { Object.assign(this.rootConfig, obj); } /* * Process the userland plugins from the Config * * @param {object} - the return Object from the user’s config file. */ async processPlugins({ dir, pathPrefix }) { this.userConfig.dir = dir; this.userConfig.pathPrefix = pathPrefix; // for Nested addPlugin calls, Issue #1925 this.userConfig._enablePluginExecution(); let storedActiveNamespace = this.userConfig.activeNamespace; for (let { plugin, options, pluginNamespace } of this.userConfig.plugins) { try { this.userConfig.activeNamespace = pluginNamespace; await this.userConfig._executePlugin(plugin, options); } catch (e) { let name = this.userConfig._getPluginName(plugin); let namespaces = [storedActiveNamespace, pluginNamespace].filter((entry) => !!entry); let namespaceStr = ""; if (namespaces.length) { namespaceStr = ` (namespace: ${namespaces.join(".")})`; } throw new EleventyPluginError( `Error processing ${name ? `the \`${name}\`` : "a"} plugin${namespaceStr}`, e, ); } } this.userConfig.activeNamespace = storedActiveNamespace; this.userConfig._disablePluginExecution(); } /** * Fetches and executes the local configuration file * * @returns {Promise} merged - The merged config file object. */ async requireLocalConfigFile() { let localConfig = {}; let exportedConfig = {}; let path = this.projectConfigPaths.filter((path) => path).find((path) => fs.existsSync(path)); if (this.projectConfigPaths.length > 0 && this.#configManuallyDefined && !path) { throw new EleventyConfigError( "A configuration file was specified but not found: " + this.projectConfigPaths.join(", "), ); } debug(`Merging default config with ${path}`); if (path) { try { let { default: configDefaultReturn, config: exportedConfigObject } = await EleventyImportRaw(path, this.isEsm ? "esm" : "cjs"); exportedConfig = exportedConfigObject || {}; if (this.directories && Object.keys(exportedConfigObject?.dir || {}).length > 0) { debug( "Setting directories via `config.dir` export from config file: %o", exportedConfigObject.dir, ); this.directories.setViaConfigObject(exportedConfigObject.dir); } if (typeof configDefaultReturn === "function") { localConfig = await configDefaultReturn(this.userConfig); } else { localConfig = configDefaultReturn; } // Removed a check for `filters` in 3.0.0-alpha.6 (now using addTransform instead) https://v3.11ty.dev/docs/config/#transforms } catch (err) { let isModuleError = err instanceof Error && (err?.message || "").includes("Cannot find module"); // TODO the error message here is bad and I feel bad (needs more accurate info) return Promise.reject( new EleventyConfigError( `Error in your Eleventy config file '${path}'.` + (isModuleError ? chalk.cyan(" You may need to run `npm install`.") : ""), err, ), ); } } else { debug( "Project config file not found (not an error—skipping). Looked in: %o", this.projectConfigPaths, ); } return { localConfig, exportedConfig, }; } /** * Merges different config files together. * * @returns {Promise} merged - The merged config file. */ async mergeConfig() { let { localConfig, exportedConfig } = await this.requireLocalConfigFile(); // Merge `export const config = {}` with `return {}` in config callback if (isPlainObject(exportedConfig)) { localConfig = Merge(localConfig || {}, exportedConfig); } if (this.directories) { if (Object.keys(this.userConfig.directoryAssignments || {}).length > 0) { debug( "Setting directories via set*Directory configuration APIs %o", this.userConfig.directoryAssignments, ); this.directories.setViaConfigObject(this.userConfig.directoryAssignments); } if (localConfig && Object.keys(localConfig?.dir || {}).length > 0) { debug( "Setting directories via `dir` object return from configuration file: %o", localConfig.dir, ); this.directories.setViaConfigObject(localConfig.dir); } } // `templateFormats` is an override via `setTemplateFormats` if (this.userConfig?.templateFormats) { this.templateFormats.setViaConfig(this.userConfig.templateFormats); } else if (localConfig?.templateFormats || this.rootConfig?.templateFormats) { // Local project config or defaultConfig.js this.templateFormats.setViaConfig( localConfig.templateFormats || this.rootConfig?.templateFormats, ); } // `templateFormatsAdded` is additive via `addTemplateFormats` if (this.userConfig?.templateFormatsAdded) { this.templateFormats.addViaConfig(this.userConfig.templateFormatsAdded); } let mergedConfig = Merge({}, this.rootConfig, localConfig); // Setup a few properties for plugins: // Set frozen templateFormats mergedConfig.templateFormats = Object.freeze(this.templateFormats.getTemplateFormats()); // Setup pathPrefix set via command line for plugin consumption if (this.overrides.pathPrefix) { mergedConfig.pathPrefix = this.overrides.pathPrefix; } // Returning a falsy value (e.g. "") from user config should reset to the default value. if (!mergedConfig.pathPrefix) { mergedConfig.pathPrefix = this.rootConfig.pathPrefix; } // This is not set in UserConfig.js so that getters aren’t converted to strings // We want to error if someone attempts to use a setter there. if (this.directories) { mergedConfig.directories = this.directories.getUserspaceInstance(); } // Delay processing plugins until after the result of localConfig is returned // But BEFORE the rest of the config options are merged // this way we can pass directories and other template information to plugins await this.userConfig.events.emit("eleventy.beforeConfig", this.userConfig); let pluginsBench = this.aggregateBenchmark.get("Processing plugins in config"); pluginsBench.before(); await this.processPlugins(mergedConfig); pluginsBench.after(); // Template formats added via plugins if (this.userConfig?.templateFormatsAdded) { this.templateFormats.addViaConfig(this.userConfig.templateFormatsAdded); mergedConfig.templateFormats = Object.freeze(this.templateFormats.getTemplateFormats()); } let eleventyConfigApiMergingObject = this.userConfig.getMergingConfigObject(); if ("templateFormats" in eleventyConfigApiMergingObject) { throw new Error( "Internal error: templateFormats should not return from `getMergingConfigObject`", ); } // Overrides are only used by pathPrefix debug("Configuration overrides: %o", this.overrides); Merge(mergedConfig, eleventyConfigApiMergingObject, this.overrides); debug("Current configuration: %o", mergedConfig); // Add to the merged config too mergedConfig.uses = this.usesGraph; // this is used for the layouts event this.usesGraph.setConfig(mergedConfig); return mergedConfig; } get usesGraph() { if (!this._usesGraph) { this._usesGraph = new GlobalDependencyMap(); this._usesGraph.setIsEsm(this.isEsm); this._usesGraph.setTemplateConfig(this); } return this._usesGraph; } get uses() { if (!this.usesGraph) { throw new Error("The Eleventy Global Dependency Graph has not yet been initialized."); } return this.usesGraph; } get existsCache() { if (!this._existsCache) { this._existsCache = new ExistsCache(); this._existsCache.setDirectoryCheck(true); } return this._existsCache; } } export default TemplateConfig;