import fs from "node:fs"; import path from "node:path"; import { TemplatePath } from "@11ty/eleventy-utils"; import { isDynamicPattern } from "tinyglobby"; import DirContains from "./DirContains.js"; /* Directories internally should always use *nix forward slashes */ class ProjectDirectories { static defaults = { input: "./", data: "./_data/", // Relative to input directory includes: "./_includes/", // Relative to input directory layouts: "./_layouts/", // Relative to input directory output: "./_site/", }; // no updates allowed, input/output set via CLI #frozen = false; #raw = {}; #dirs = {}; inputFile = undefined; inputGlob = undefined; // Add leading dot slash // Use forward slashes static normalizePath(fileOrDir) { return TemplatePath.standardizeFilePath(fileOrDir); } // Must be a directory // Always include a trailing slash static normalizeDirectory(dir) { return this.addTrailingSlash(this.normalizePath(dir)); } normalizeDirectoryPathRelativeToInputDirectory(filePath) { return ProjectDirectories.normalizeDirectory(path.join(this.input, filePath)); } static addTrailingSlash(path) { if (path.slice(-1) === "/") { return path; } return path + "/"; } // If input/output are set via CLI, they take precedence over all other configuration values. freeze() { this.#frozen = true; } setViaConfigObject(configDirs = {}) { // input must come last let inputChanged = false; if ( configDirs.input && ProjectDirectories.normalizeDirectory(configDirs.input) !== this.input ) { this.#setInputRaw(configDirs.input); inputChanged = true; } // If falsy or an empty string, the current directory is used. if (configDirs.output !== undefined) { if (ProjectDirectories.normalizeDirectory(configDirs.output) !== this.output) { this.setOutput(configDirs.output); } } // Input relative directory, if falsy or an empty string, inputDir is used! // Always set if input changed, e.g. input is `src` and data is `../_data` (resulting in `./_data`) we still want to set data to this new value if (configDirs.data !== undefined) { if ( inputChanged || this.normalizeDirectoryPathRelativeToInputDirectory(configDirs.data || "") !== this.data ) { this.setData(configDirs.data); } } // Input relative directory, if falsy or an empty string, inputDir is used! if (configDirs.includes !== undefined) { if ( inputChanged || this.normalizeDirectoryPathRelativeToInputDirectory(configDirs.includes || "") !== this.includes ) { this.setIncludes(configDirs.includes); } } // Input relative directory, if falsy or an empty string, inputDir is used! if (configDirs.layouts !== undefined) { if ( inputChanged || this.normalizeDirectoryPathRelativeToInputDirectory(configDirs.layouts || "") !== this.layouts ) { this.setLayouts(configDirs.layouts); } } if (inputChanged) { this.updateInputDependencies(); } } updateInputDependencies() { // raw first, fall back to Eleventy defaults if not yet set this.setData(this.#raw.data ?? ProjectDirectories.defaults.data); this.setIncludes(this.#raw.includes ?? ProjectDirectories.defaults.includes); // Should not include this if not explicitly opted-in if (this.#raw.layouts !== undefined) { this.setLayouts(this.#raw.layouts ?? ProjectDirectories.defaults.layouts); } } /* Relative to project root, must exist */ #setInputRaw(dirOrFile, inputDir = undefined) { // is frozen and was defined previously if (this.#frozen && this.#raw.input !== undefined) { return; } this.#raw.input = dirOrFile; if (!dirOrFile) { // input must exist if inputDir is not set. return; } // Normalize absolute paths to relative, #3805 // if(path.isAbsolute(dirOrFile)) { // dirOrFile = path.relative(".", dirOrFile); // } // Input has to exist (assumed glob if it does not exist) let inputExists = fs.existsSync(dirOrFile); let inputExistsAndIsDirectory = inputExists && fs.statSync(dirOrFile).isDirectory(); if (inputExistsAndIsDirectory) { // is not a file or glob this.#dirs.input = ProjectDirectories.normalizeDirectory(dirOrFile); } else { if (inputExists) { this.inputFile = ProjectDirectories.normalizePath(dirOrFile); } else { if (!isDynamicPattern(dirOrFile)) { throw new Error( `The "${dirOrFile}" \`input\` parameter (directory or file path) must exist on the file system (unless detected as a glob by the \`tinyglobby\` package)`, ); } this.inputGlob = dirOrFile; } // Explicit Eleventy option for inputDir if (inputDir) { // Changed in 3.0: must exist if (!fs.existsSync(inputDir)) { throw new Error("Directory must exist (via inputDir option to Eleventy constructor)."); } this.#dirs.input = ProjectDirectories.normalizeDirectory(inputDir); } else { // the input directory is implied to be the parent directory of the // file, unless inputDir is explicitly specified (via Eleventy constructor `options`) this.#dirs.input = ProjectDirectories.normalizeDirectory( TemplatePath.getDirFromFilePath(dirOrFile), // works with globs ); } } } setInput(dirOrFile, inputDir = undefined) { this.#setInputRaw(dirOrFile, inputDir); // does not update this.updateInputDependencies(); } /* Relative to input dir */ setIncludes(dir) { if (dir !== undefined) { // falsy or an empty string is valid (falls back to input dir) this.#raw.includes = dir; this.#dirs.includes = ProjectDirectories.normalizeDirectory( TemplatePath.join(this.input, dir || ""), ); } } /* Relative to input dir */ /* Optional */ setLayouts(dir) { if (dir !== undefined) { // falsy or an empty string is valid (falls back to input dir) this.#raw.layouts = dir; this.#dirs.layouts = ProjectDirectories.normalizeDirectory( TemplatePath.join(this.input, dir || ""), ); } } /* Relative to input dir */ setData(dir) { if (dir !== undefined) { // falsy or an empty string is valid (falls back to input dir) // TODO must exist if specified this.#raw.data = dir; this.#dirs.data = ProjectDirectories.normalizeDirectory( TemplatePath.join(this.input, dir || ""), ); } } /* Relative to project root */ setOutput(dir) { // is frozen and was defined previously if (this.#frozen && this.#raw.output !== undefined) { return; } if (dir !== undefined) { this.#raw.output = dir; this.#dirs.output = ProjectDirectories.normalizeDirectory(dir || ""); } } get input() { return this.#dirs.input || ProjectDirectories.defaults.input; } get data() { return this.#dirs.data || ProjectDirectories.defaults.data; } get includes() { return this.#dirs.includes || ProjectDirectories.defaults.includes; } get layouts() { // explicit opt-in, no fallback. return this.#dirs.layouts; } get output() { return this.#dirs.output || ProjectDirectories.defaults.output; } isTemplateFile(filePath) { let inputPath = this.getInputPath(filePath); // TODO use DirContains if (this.layouts && inputPath.startsWith(this.layouts)) { return false; } // if this.includes is "" (and thus is the same directory as this.input) // we don’t actually know if this is a template file, so defer if (this.includes && this.includes !== this.input) { if (inputPath.startsWith(this.includes)) { return false; } } // TODO use DirContains return inputPath.startsWith(this.input); } // for a hypothetical template file getInputPath(filePathRelativeToInputDir) { // TODO change ~/ to project root dir return TemplatePath.addLeadingDotSlash( TemplatePath.join(this.input, TemplatePath.standardizeFilePath(filePathRelativeToInputDir)), ); } // Inverse of getInputPath // Removes input dir from path getInputPathRelativeToInputDirectory(filePathRelativeToInputDir) { let inputDir = TemplatePath.addLeadingDotSlash(TemplatePath.join(this.input)); // No leading dot slash return TemplatePath.stripLeadingSubPath(filePathRelativeToInputDir, inputDir); } // for a hypothetical Eleventy layout file getLayoutPath(filePathRelativeToLayoutDir) { return TemplatePath.addLeadingDotSlash( TemplatePath.join( this.layouts || this.includes, TemplatePath.standardizeFilePath(filePathRelativeToLayoutDir), ), ); } // Removes layout dir from path getLayoutPathRelativeToInputDirectory(filePathRelativeToLayoutDir) { let layoutPath = this.getLayoutPath(filePathRelativeToLayoutDir); let inputDir = TemplatePath.addLeadingDotSlash(TemplatePath.join(this.input)); // No leading dot slash return TemplatePath.stripLeadingSubPath(layoutPath, inputDir); } getProjectPath(filePath) { return TemplatePath.addLeadingDotSlash( TemplatePath.join(".", TemplatePath.standardizeFilePath(filePath)), ); } isFileInProjectFolder(filePath) { return DirContains(TemplatePath.getWorkingDir(), filePath); } isFileInOutputFolder(filePath) { return DirContains(this.output, filePath); } static getRelativeTo(targetPath, cwd) { return path.relative(cwd, path.join(path.resolve("."), targetPath)); } // Access the data without being able to set the data. getUserspaceInstance() { let d = this; return { get input() { return d.input; }, get inputFile() { return d.inputFile; }, get inputGlob() { return d.inputGlob; }, get data() { return d.data; }, get includes() { return d.includes; }, get layouts() { return d.layouts; }, get output() { return d.output; }, }; } toString() { return { input: this.input, inputFile: this.inputFile, inputGlob: this.inputGlob, data: this.data, includes: this.includes, layouts: this.layouts, output: this.output, }; } } export default ProjectDirectories;