diff --git a/package-lock.json b/package-lock.json
index f3bc74229..4d7091abb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,7 @@
"@11ty/eleventy-utils": "2.0.0-alpha.2",
"@11ty/lodash-custom": "^4.17.21",
"@11ty/posthtml-urls": "^1.0.0",
- "@11ty/recursive-copy": "^3.0.0",
+ "@11ty/recursive-copy": "^3.0.1",
"@sindresorhus/slugify": "^2.2.1",
"bcp-47-normalize": "^2.3.0",
"chardet": "^2.0.0",
@@ -359,9 +359,10 @@
}
},
"node_modules/@11ty/recursive-copy": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-3.0.0.tgz",
- "integrity": "sha512-v1Mr7dWx5nk69/HRRtDHUYDV9N8+cE12IGiKSFOwML7HjOzUXwTP88e3cGuhqoVstkBil1ZEIaOB0KPP1zwqXA==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-3.0.1.tgz",
+ "integrity": "sha512-suoSv7CanyKXIwwtLlzP43n3Mm3MTR7UzaLgnG+JP9wAdg4uCIUJiAhhgs/nkwtkvsuqfrGWrUiaG1K9mEoiPg==",
+ "license": "ISC",
"dependencies": {
"errno": "^0.1.2",
"graceful-fs": "^4.2.11",
diff --git a/package.json b/package.json
index 73a05b862..162fa2ac0 100644
--- a/package.json
+++ b/package.json
@@ -125,7 +125,7 @@
"@11ty/eleventy-utils": "2.0.0-alpha.2",
"@11ty/lodash-custom": "^4.17.21",
"@11ty/posthtml-urls": "^1.0.0",
- "@11ty/recursive-copy": "^3.0.0",
+ "@11ty/recursive-copy": "^3.0.1",
"@sindresorhus/slugify": "^2.2.1",
"bcp-47-normalize": "^2.3.0",
"chardet": "^2.0.0",
diff --git a/src/Eleventy.js b/src/Eleventy.js
index 9d19e7e7a..1a1787037 100644
--- a/src/Eleventy.js
+++ b/src/Eleventy.js
@@ -518,9 +518,9 @@ class Eleventy {
}
this.templateData.setFileSystemSearch(this.fileSystemSearch);
- // TODO swap this to getters
this.passthroughManager = new TemplatePassthroughManager(this.eleventyConfig);
this.passthroughManager.setRunMode(this.runMode);
+ this.passthroughManager.setDryRun(this.isDryRun);
this.passthroughManager.extensionMap = this.extensionMap;
this.passthroughManager.setFileSystemSearch(this.fileSystemSearch);
diff --git a/src/Plugins/HtmlBasePlugin.js b/src/Plugins/HtmlBasePlugin.js
index da4faffc4..6a711aaa6 100644
--- a/src/Plugins/HtmlBasePlugin.js
+++ b/src/Plugins/HtmlBasePlugin.js
@@ -137,7 +137,7 @@ function eleventyHtmlBasePlugin(eleventyConfig, defaultOptions = {}) {
});
},
{
- priority: -1, // run last (especially after PathToUrl transform)
+ priority: -2, // priority is descending, so this runs last (especially after AutoCopy and InputPathToUrl transform)
enabled: function (context) {
// Enabled when pathPrefix is non-default or via renderTransforms
return Boolean(context.baseHref) || opts.baseHref !== "/";
diff --git a/src/Plugins/HtmlRelativeCopyPlugin.js b/src/Plugins/HtmlRelativeCopyPlugin.js
new file mode 100644
index 000000000..ac1391dce
--- /dev/null
+++ b/src/Plugins/HtmlRelativeCopyPlugin.js
@@ -0,0 +1,52 @@
+import { HtmlRelativeCopy } from "../Util/HtmlRelativeCopy.js";
+
+// one HtmlRelativeCopy instance per entry
+function init(eleventyConfig, options) {
+ let opts = Object.assign(
+ {
+ extensions: "html",
+ match: false, // can be one glob string or an array of globs
+ paths: [], // directories to also look in for files
+ failOnError: true, // fails when a path matches (via `match`) but not found on file system
+ copyOptions: undefined,
+ },
+ options,
+ );
+
+ let htmlrel = new HtmlRelativeCopy();
+ htmlrel.setUserConfig(eleventyConfig);
+ htmlrel.addMatchingGlob(opts.match);
+ htmlrel.setFailOnError(opts.failOnError);
+ htmlrel.setCopyOptions(opts.copyOptions);
+
+ eleventyConfig.htmlTransformer.addUrlTransform(
+ opts.extensions,
+ function (targetFilepathOrUrl) {
+ // @ts-ignore
+ htmlrel.copy(targetFilepathOrUrl, this.page.inputPath, this.page.outputPath);
+
+ // TODO front matter option for manual copy
+ return targetFilepathOrUrl;
+ },
+ {
+ enabled: () => htmlrel.isEnabled(),
+ // - MUST run after other plugins but BEFORE HtmlBase plugin
+ priority: -1,
+ },
+ );
+
+ htmlrel.addPaths(opts.paths);
+}
+
+function HtmlRelativeCopyPlugin(eleventyConfig) {
+ // Important: if this is empty, no URL transforms are added
+ for (let options of eleventyConfig.passthroughCopiesHtmlRelative) {
+ init(eleventyConfig, options);
+ }
+}
+
+Object.defineProperty(HtmlRelativeCopyPlugin, "eleventyPackage", {
+ value: "@11ty/eleventy/html-relative-copy-plugin",
+});
+
+export { HtmlRelativeCopyPlugin };
diff --git a/src/TemplatePassthrough.js b/src/TemplatePassthrough.js
index 791fad2fd..3ee71ef49 100644
--- a/src/TemplatePassthrough.js
+++ b/src/TemplatePassthrough.js
@@ -18,21 +18,19 @@ class TemplatePassthrough {
#isInputPathGlob;
#benchmarks;
#isAlreadyNormalized = false;
+ #projectDirCheck = false;
- // paths already guaranteed (probably from the autocopy plugin)
- static normalizedFactory(inputPath, outputPath, opts = {}) {
+ // paths already guaranteed from the autocopy plugin
+ static factory(inputPath, outputPath, opts = {}) {
let p = new TemplatePassthrough(
{
inputPath,
outputPath,
+ copyOptions: opts.copyOptions,
},
opts.templateConfig,
);
- if (opts.normalized) {
- p.setIsAlreadyNormalized(true);
- }
-
return p;
}
@@ -69,6 +67,10 @@ class TemplatePassthrough {
return this.templateConfig.getConfig();
}
+ get directories() {
+ return this.templateConfig.directories;
+ }
+
// inputDir is used when stripping from output path in `getOutputPath`
get inputDir() {
return this.templateConfig.directories.input;
@@ -78,10 +80,15 @@ class TemplatePassthrough {
return this.templateConfig.directories.output;
}
+ // Skips `getFiles()` normalization
setIsAlreadyNormalized(isNormalized) {
this.#isAlreadyNormalized = Boolean(isNormalized);
}
+ setCheckSourceDirectory(check) {
+ this.#projectDirCheck = Boolean(check);
+ }
+
/* { inputPath, outputPath } though outputPath is *not* the full path: just the output directory */
getPath() {
return this.rawPath;
@@ -239,11 +246,15 @@ class TemplatePassthrough {
* 3. individual file
*/
async copy(src, dest, copyOptions) {
- if (
- !TemplatePath.stripLeadingDotSlash(dest).startsWith(
- TemplatePath.stripLeadingDotSlash(this.outputDir),
- )
- ) {
+ if (this.#projectDirCheck && !this.directories.isFileInProjectFolder(src)) {
+ return Promise.reject(
+ new TemplatePassthroughError(
+ "Source file is not in the project directory. Check your passthrough paths.",
+ ),
+ );
+ }
+
+ if (!this.directories.isFileInOutputFolder(dest)) {
return Promise.reject(
new TemplatePassthroughError(
"Destination is not in the site output directory. Check your passthrough paths.",
@@ -255,6 +266,7 @@ class TemplatePassthrough {
let fileSizeCount = 0;
let map = {};
let b = this.benchmarks.aggregate.get("Passthrough Copy File");
+
// returns a promise
return copy(src, dest, copyOptions)
.on(copy.events.COPY_FILE_START, (copyOp) => {
@@ -268,13 +280,28 @@ class TemplatePassthrough {
fileSizeCount += copyOp.stats.size;
b.after();
})
- .then(() => {
- return {
- count: fileCopyCount,
- size: fileSizeCount,
- map,
- };
- });
+ .then(
+ () => {
+ return {
+ count: fileCopyCount,
+ size: fileSizeCount,
+ map,
+ };
+ },
+ (error) => {
+ if (copyOptions.overwrite === false && error.code === "EEXIST") {
+ // just ignore if the output already exists and overwrite: false
+ debug("Overwrite error ignored: %O", error);
+ return {
+ count: 0,
+ size: 0,
+ map,
+ };
+ }
+
+ return Promise.reject(error);
+ },
+ );
}
async write() {
diff --git a/src/TemplatePassthroughManager.js b/src/TemplatePassthroughManager.js
index 295859a68..1983bf7e2 100644
--- a/src/TemplatePassthroughManager.js
+++ b/src/TemplatePassthroughManager.js
@@ -7,22 +7,39 @@ import EleventyBaseError from "./Errors/EleventyBaseError.js";
import TemplatePassthrough from "./TemplatePassthrough.js";
import checkPassthroughCopyBehavior from "./Util/PassthroughCopyBehaviorCheck.js";
import { isGlobMatch } from "./Util/GlobMatcher.js";
+import { withResolvers } from "./Util/PromiseUtil.js";
const debug = debugUtil("Eleventy:TemplatePassthroughManager");
-class TemplatePassthroughManagerConfigError extends EleventyBaseError {}
class TemplatePassthroughManagerCopyError extends EleventyBaseError {}
class TemplatePassthroughManager {
#isDryRun = false;
+ #afterBuild;
+ #queue = new Map();
constructor(templateConfig) {
if (!templateConfig || templateConfig.constructor.name !== "TemplateConfig") {
- throw new TemplatePassthroughManagerConfigError("Missing or invalid `config` argument.");
+ throw new Error("Internal error: Missing or invalid `templateConfig` argument.");
}
this.templateConfig = templateConfig;
this.config = templateConfig.getConfig();
+
+ // eleventy# event listeners are removed on each build
+ this.config.events.on("eleventy#copy", ({ source, target, options }) => {
+ this.enqueueCopy(source, target, options);
+ });
+
+ this.config.events.on("eleventy#beforerender", () => {
+ this.#afterBuild = withResolvers();
+ });
+
+ this.config.events.on("eleventy#render", () => {
+ let { resolve } = this.#afterBuild;
+ resolve();
+ });
+
this.reset();
}
@@ -31,7 +48,8 @@ class TemplatePassthroughManager {
this.size = 0;
this.conflictMap = {};
this.incrementalFile;
- debug("Resetting counts to 0");
+
+ this.#queue = new Map();
}
set extensionMap(extensionMap) {
@@ -288,9 +306,36 @@ class TemplatePassthroughManager {
return entries;
}
- // Performance note: these can actually take a fair bit of time, but aren’t a
- // bottleneck to eleventy. The copies are performed asynchronously and don’t affect eleventy
- // write times in a significant way.
+ async #waitForTemplatesRendered() {
+ if (!this.#afterBuild) {
+ return Promise.resolve(); // immediately resolve
+ }
+
+ let { promise } = this.#afterBuild;
+ return promise;
+ }
+
+ enqueueCopy(source, target, copyOptions) {
+ let key = `${source}=>${target}`;
+
+ // light de-dupe the same source/target combo (might be in the same file, might be viaTransforms)
+ if (this.#queue.has(key)) {
+ return;
+ }
+
+ let passthrough = TemplatePassthrough.factory(source, target, {
+ templateConfig: this.templateConfig,
+ copyOptions,
+ });
+
+ passthrough.setCheckSourceDirectory(true);
+ passthrough.setIsAlreadyNormalized(true);
+ passthrough.setRunMode(this.runMode);
+ passthrough.setDryRun(this.#isDryRun);
+
+ this.#queue.set(key, this.copyPassthrough(passthrough));
+ }
+
async copyAll(templateExtensionPaths) {
debug("TemplatePassthrough copy started.");
let normalizedPaths = this.getAllNormalizedPaths(templateExtensionPaths);
@@ -298,6 +343,13 @@ class TemplatePassthroughManager {
let passthroughs = normalizedPaths.map((path) => this.getTemplatePassthroughForPath(path));
let promises = passthroughs.map((pass) => this.copyPassthrough(pass));
+
+ await this.#waitForTemplatesRendered();
+
+ for (let [key, afterBuildCopyPromises] of this.#queue) {
+ promises.push(afterBuildCopyPromises);
+ }
+
return Promise.all(promises).then(async (results) => {
let aliases = this.getAliasesFromPassthroughResults(results);
await this.config.events.emit("eleventy.passthrough", {
diff --git a/src/TemplateWriter.js b/src/TemplateWriter.js
index 19d3010bb..92abd4e92 100755
--- a/src/TemplateWriter.js
+++ b/src/TemplateWriter.js
@@ -429,18 +429,20 @@ class TemplateWriter {
async write() {
let paths = await this._getAllPaths();
- let promises = [];
- // The ordering here is important to destructuring in Eleventy->_watch
- promises.push(this.writePassthroughCopy(paths));
+ // This must happen before writePassthroughCopy
+ this.templateConfig.userConfig.emit("eleventy#beforerender");
- promises.push(...(await this.generateTemplates(paths)));
+ let aggregatePassthroughCopyPromise = this.writePassthroughCopy(paths);
- return Promise.all(promises).then(
- async ([passthroughCopyResults, ...templateResults]) => {
- // TODO wait for afterBuildCopy to finish
- // console.log( "AFTER??", passthroughCopyResults );
+ let templatesPromise = Promise.all(await this.generateTemplates(paths)).then((results) => {
+ this.templateConfig.userConfig.emit("eleventy#render");
+
+ return results;
+ });
+ return Promise.all([aggregatePassthroughCopyPromise, templatesPromise]).then(
+ async ([passthroughCopyResults, templateResults]) => {
return {
passthroughCopy: passthroughCopyResults,
// New in 3.0: flatten and filter out falsy templates
diff --git a/src/UserConfig.js b/src/UserConfig.js
index d1edc26f0..e694756f5 100644
--- a/src/UserConfig.js
+++ b/src/UserConfig.js
@@ -145,6 +145,7 @@ class UserConfig {
/** @type {object} */
this.passthroughCopies = {};
+ this.passthroughCopiesHtmlRelative = new Set();
/** @type {object} */
this.layoutAliases = {};
@@ -791,7 +792,18 @@ class UserConfig {
* @returns {any} a reference to the `EleventyConfig` object.
*/
addPassthroughCopy(fileOrDir, copyOptions = {}) {
- if (typeof fileOrDir === "string") {
+ if (copyOptions.mode === "html-relative") {
+ if (isPlainObject(fileOrDir)) {
+ throw new Error(
+ "mode: 'html-relative' does not yet support passthrough copy objects (input -> output mapping). Use a string glob or an Array of string globs.",
+ );
+ }
+
+ this.passthroughCopiesHtmlRelative?.add({
+ match: fileOrDir,
+ ...copyOptions,
+ });
+ } else if (typeof fileOrDir === "string") {
this.passthroughCopies[fileOrDir] = { outputPath: true, copyOptions };
} else {
for (let [inputPath, outputPath] of Object.entries(fileOrDir)) {
@@ -1215,6 +1227,7 @@ class UserConfig {
globalData: this.globalData,
layoutAliases: this.layoutAliases,
layoutResolution: this.layoutResolution,
+ passthroughCopiesHtmlRelative: this.passthroughCopiesHtmlRelative,
passthroughCopies: this.passthroughCopies,
// Liquid
diff --git a/src/Util/GlobMatcher.js b/src/Util/GlobMatcher.js
index 8af08797f..b5c54e90d 100644
--- a/src/Util/GlobMatcher.js
+++ b/src/Util/GlobMatcher.js
@@ -15,6 +15,7 @@ function isGlobMatch(filepath, globs = [], options = undefined) {
options,
);
+ // globs: string or array of strings
return micromatch.isMatch(inputPath, globs, opts);
}
diff --git a/src/Util/HtmlRelativeCopy.js b/src/Util/HtmlRelativeCopy.js
new file mode 100644
index 000000000..305901477
--- /dev/null
+++ b/src/Util/HtmlRelativeCopy.js
@@ -0,0 +1,149 @@
+import path from "node:path";
+import { TemplatePath } from "@11ty/eleventy-utils";
+import isValidUrl from "./ValidUrl.js";
+import { isGlobMatch } from "./GlobMatcher.js";
+
+class HtmlRelativeCopy {
+ #userConfig;
+ #matchingGlobs = new Set();
+ #matchingGlobsArray;
+ #dirty = false;
+ #paths = new Set();
+ #failOnError = true;
+ #copyOptions = {
+ dot: false, // differs from standard passthrough copy
+ };
+
+ isEnabled() {
+ return this.#matchingGlobs.size > 0;
+ }
+
+ setFailOnError(failOnError) {
+ this.#failOnError = Boolean(failOnError);
+ }
+
+ setCopyOptions(opts) {
+ if (opts) {
+ Object.assign(this.#copyOptions, opts);
+ }
+ }
+
+ setUserConfig(userConfig) {
+ if (!userConfig || userConfig.constructor.name !== "UserConfig") {
+ throw new Error(
+ "Internal error: Missing `userConfig` or was not an instance of `UserConfig`.",
+ );
+ }
+ this.#userConfig = userConfig;
+ }
+
+ addPaths(paths = []) {
+ for (let path of paths) {
+ this.#paths.add(TemplatePath.getDir(path));
+ }
+ }
+
+ get matchingGlobs() {
+ if (this.#dirty || !this.#matchingGlobsArray) {
+ this.#matchingGlobsArray = Array.from(this.#matchingGlobs);
+ this.#dirty = false;
+ }
+
+ return this.#matchingGlobsArray;
+ }
+
+ addMatchingGlob(glob) {
+ if (glob) {
+ if (Array.isArray(glob)) {
+ for (let g of glob) {
+ this.#matchingGlobs.add(g);
+ }
+ } else {
+ this.#matchingGlobs.add(glob);
+ }
+ this.#dirty = true;
+ }
+ }
+
+ isSkippableHref(rawRef) {
+ if (
+ this.#matchingGlobs.size === 0 ||
+ !rawRef ||
+ path.isAbsolute(rawRef) ||
+ isValidUrl(rawRef)
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ isCopyableTarget(target) {
+ if (!isGlobMatch(target, this.matchingGlobs)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ exists(filePath) {
+ return this.#userConfig.exists(filePath);
+ }
+
+ getAliasedPath(ref) {
+ for (let dir of this.#paths) {
+ let found = TemplatePath.join(dir, ref);
+ if (this.isCopyableTarget(found) && this.exists(found)) {
+ return found;
+ }
+ }
+ }
+
+ getFilePathRelativeToProjectRoot(ref, contextFilePath) {
+ let dir = TemplatePath.getDirFromFilePath(contextFilePath);
+ return TemplatePath.join(dir, ref);
+ }
+
+ copy(fileRef, tmplInputPath, tmplOutputPath) {
+ // original ref is a full URL or no globs exist
+ if (this.isSkippableHref(fileRef)) {
+ return;
+ }
+
+ // Relative to source file’s input path
+ let source = this.getFilePathRelativeToProjectRoot(fileRef, tmplInputPath);
+ if (!this.isCopyableTarget(source)) {
+ return;
+ }
+
+ if (!this.exists(source)) {
+ // Try to alias using `options.paths`
+ let alias = this.getAliasedPath(fileRef);
+ if (!alias) {
+ if (this.#failOnError) {
+ throw new Error(
+ "Missing input file for `html-relative` Passthrough Copy file: " +
+ TemplatePath.absolutePath(source),
+ );
+ }
+
+ // don’t fail on error
+ return;
+ }
+
+ source = alias;
+ }
+
+ let target = this.getFilePathRelativeToProjectRoot(fileRef, tmplOutputPath);
+
+ // We use a Set here to allow passthrough copy manager to properly error on conflicts upstream
+ // Only errors when different inputs write to the same output
+ // Also errors if attempts to write outside the output folder.
+ this.#userConfig.emit("eleventy#copy", {
+ source,
+ target,
+ options: this.#copyOptions,
+ });
+ }
+}
+
+export { HtmlRelativeCopy };
diff --git a/src/Util/HtmlTransformer.js b/src/Util/HtmlTransformer.js
index 3d5cd68d3..1f9515a65 100644
--- a/src/Util/HtmlTransformer.js
+++ b/src/Util/HtmlTransformer.js
@@ -79,7 +79,7 @@ class HtmlTransformer {
target[ext].push({
fn: value, // callback or plugin
- priority: options.priority,
+ priority: options.priority, // sorted in descending order
enabled: options.enabled || (() => true),
options: options.pluginOptions,
});
diff --git a/src/Util/ProjectDirectories.js b/src/Util/ProjectDirectories.js
index 5c4a9998d..0550498e5 100644
--- a/src/Util/ProjectDirectories.js
+++ b/src/Util/ProjectDirectories.js
@@ -299,6 +299,14 @@ class ProjectDirectories {
);
}
+ isFileInProjectFolder(filePath) {
+ return TemplatePath.absolutePath(filePath).startsWith(TemplatePath.getWorkingDir());
+ }
+
+ isFileInOutputFolder(filePath) {
+ return TemplatePath.absolutePath(filePath).startsWith(TemplatePath.absolutePath(this.output));
+ }
+
// Access the data without being able to set the data.
getUserspaceInstance() {
let d = this;
diff --git a/src/defaultConfig.js b/src/defaultConfig.js
index 8cd985f74..eaf22b26a 100644
--- a/src/defaultConfig.js
+++ b/src/defaultConfig.js
@@ -9,6 +9,7 @@ import { FilterPlugin as InputPathToUrlFilterPlugin } from "./Plugins/InputPathT
import { HtmlTransformer } from "./Util/HtmlTransformer.js";
import TransformsUtil from "./Util/TransformsUtil.js";
import MemoizeUtil from "./Util/MemoizeFunction.js";
+import { HtmlRelativeCopyPlugin } from "./Plugins/HtmlRelativeCopyPlugin.js";
/**
* @module 11ty/eleventy/defaultConfig
@@ -65,6 +66,10 @@ export default function (config) {
// This needs to be assigned before bundlePlugin is added below.
config.htmlTransformer = ut;
+ config.exists = (filePath) => {
+ return this.existsCache.exists(filePath);
+ };
+
config.addPlugin(bundlePlugin, {
bundles: false, // no default bundles included—must be opt-in.
immediate: true,
@@ -126,6 +131,9 @@ export default function (config) {
return ut.transformContent(this.outputPath, content, this);
});
+ // Requires user configuration, so must run as second-stage
+ config.addPlugin(HtmlRelativeCopyPlugin);
+
return {
templateFormats: ["liquid", "md", "njk", "html", "11ty.js"],
// if your site deploys to a subdirectory, change this
diff --git a/test/HtmlRelativeCopyTest.js b/test/HtmlRelativeCopyTest.js
new file mode 100644
index 000000000..d08ada84b
--- /dev/null
+++ b/test/HtmlRelativeCopyTest.js
@@ -0,0 +1,599 @@
+import test from "ava";
+import fs from "node:fs";
+import { rimrafSync } from "rimraf";
+import { TemplatePath } from "@11ty/eleventy-utils";
+
+import { TransformPlugin as InputPathToUrlTransformPlugin } from "../src/Plugins/InputPathToUrl.js";
+import { default as HtmlBasePlugin } from "../src/Plugins/HtmlBasePlugin.js";
+import Eleventy from "../src/Eleventy.js";
+
+test("Basic usage", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site-basica", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative"
+ })
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {
+ "/test/possum.png": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")
+ }
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 1);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site-basica/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site-basica/test/possum.png"),
+ }
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site-basica/test/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site-basica/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site-basica");
+});
+
+test("More complex image path (parent dir)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site-basicb", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative"
+ })
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {
+ "/stubs-img-transform/possum.png": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-img-transform/possum.png")
+ }
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 1);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site-basicb/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 1,
+ map: {
+ // test/stubs-autocopy/test.njk => "../stubs-img-transform/possum.png"
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-img-transform/possum.png")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site-basicb/stubs-img-transform/possum.png"),
+ }
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site-basicb/stubs-img-transform/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site-basicb/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site-basicb");
+});
+
+test("No matches", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site2", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.jpeg", {
+ mode: "html-relative"
+ })
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 0);
+ t.is(templates.length, 1);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site2/test/lol.lol"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site2/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site2");
+});
+
+test("Match but does not exist (throws error)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site3", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ await t.throwsAsync(async () => {
+ await elev.write();
+ }, {
+ message: `Having trouble writing to "./test/stubs-autocopy/_site3/test/index.html" from "./test/stubs-autocopy/test.njk"`
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site3/test/index.html"), false);
+});
+
+test("Match but does not exist (no error, using `failOnError: false`)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site4", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative",
+ failOnError: false,
+ })
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 0);
+ t.is(templates.length, 1);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site4/test/missing.png"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site4/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site4");
+});
+
+test("Copying dotfiles are not allowed", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site5", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ // WARNING: don’t do this
+ eleventyConfig.addPassthroughCopy("**/*", {
+ mode: "html-relative",
+ copyOptions: {
+ // debug: true,
+ }
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 1);
+ t.is(copy[0].count, 0);
+ t.is(templates.length, 1);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site5/.gitkeep"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site5/test/.gitkeep"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site5/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site5");
+});
+
+test("Using with InputPathToUrl plugin", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site6", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ // order of addPlugin shouldn’t matter here
+ eleventyConfig.addPassthroughCopy("**/*.{html,njk}", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.addPlugin(InputPathToUrlTransformPlugin);
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test1.njk", `Test 1`)
+ eleventyConfig.addTemplate("test2.njk", `Test 2`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 0);
+ t.is(templates.length, 2);
+
+ t.is(templates.filter(entry => entry.url.endsWith("/test2/"))[0].content, `Test 2`);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site6/test2/test1.njk"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site6/test2/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site6");
+});
+
+test("Using with InputPathToUrl plugin (reverse addPlugin order)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site7", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ // order of addPlugin shouldn’t matter here
+ eleventyConfig.addPlugin(InputPathToUrlTransformPlugin);
+
+ eleventyConfig.addPassthroughCopy("**/*.{html,njk}", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test1.njk", `Test 1`)
+ eleventyConfig.addTemplate("test2.njk", `Test 2`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 0);
+ t.is(templates.length, 2);
+ t.is(templates.filter(entry => entry.url.endsWith("/test2/"))[0].content, `Test 2`);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site7/test2/test1.njk"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site7/test2/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site7");
+});
+
+test("Use with HtmlBasePlugin usage", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site8a", {
+ configPath: false,
+ pathPrefix: "yolo",
+ config: function (eleventyConfig) {
+ eleventyConfig.addPlugin(HtmlBasePlugin);
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {
+ "/test/possum.png": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")
+ }
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 1);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site8a/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site8a/test/possum.png"),
+ }
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site8a/test/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site8a/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site8a");
+});
+
+test("Using with InputPathToUrl plugin and HtmlBasePlugin", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site8b", {
+ configPath: false,
+ pathPrefix: "yolo",
+ config: function (eleventyConfig) {
+ // order of addPlugin shouldn’t matter here
+ eleventyConfig.addPassthroughCopy("**/*.{html,njk}", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.addPlugin(InputPathToUrlTransformPlugin);
+ eleventyConfig.addPlugin(HtmlBasePlugin);
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test1.njk", `Test 1`)
+ eleventyConfig.addTemplate("test2.njk", `Test 2`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 0);
+ t.is(templates.length, 2);
+
+ t.is(templates.filter(entry => entry.url.endsWith("/test2/"))[0].content, `Test 2`);
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site8b/test2/test1.njk"), false);
+ t.is(fs.existsSync("test/stubs-autocopy/_site8b/test2/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site8b");
+});
+
+test("Multiple addPlugin calls (use both globs)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site9", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.jpg", {
+ mode: "html-relative"
+ });
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {
+ "/test/possum.jpg": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.jpg"),
+ "/test/possum.png": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png"),
+ }
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 2);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site9/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site9/test/possum.png"),
+ }
+ });
+ t.deepEqual(copy[1], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.jpg")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site9/test/possum.jpg"),
+ }
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site9/test/possum.jpg"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site9/test/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site9/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site9");
+});
+
+test("Array of globs", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site10", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy(["**/*.jpg", "**/*.png"], {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {
+ "/test/possum.jpg": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.jpg"),
+ "/test/possum.png": TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png"),
+ }
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 2);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site10/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.png")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site10/test/possum.png"),
+ }
+ });
+ t.deepEqual(copy[1], {
+ count: 1,
+ map: {
+ [TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/possum.jpg")]: TemplatePath.normalizeOperatingSystemFilePath("test/stubs-autocopy/_site10/test/possum.jpg"),
+ }
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site10/test/possum.jpg"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site10/test/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site10/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site10");
+});
+
+test("overwrite: false", async (t) => {
+ fs.mkdirSync("./test/stubs-autocopy/_site11/test/", { recursive: true })
+ fs.copyFileSync("./test/stubs-autocopy/possum.png", "./test/stubs-autocopy/_site11/test/possum.png");
+
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site11", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.png", {
+ mode: "html-relative",
+ copyOptions: {
+ overwrite: false,
+ }
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, {
+ map: {}
+ })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ let [copy, templates] = await elev.write();
+
+ t.is(copy.length, 1);
+ t.is(templates.length, 1);
+
+ t.deepEqual(templates[0], {
+ inputPath: './test/stubs-autocopy/test.njk',
+ outputPath: './test/stubs-autocopy/_site11/test/index.html',
+ url: '/test/',
+ content: '
',
+ rawInput: '
'
+ });
+
+ t.deepEqual(copy[0], {
+ count: 0,
+ map: {}
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site11/test/possum.png"), true);
+ t.is(fs.existsSync("test/stubs-autocopy/_site11/test/index.html"), true);
+});
+
+test.after.always("cleanup dirs", () => {
+ rimrafSync("test/stubs-autocopy/_site11");
+});
+
+test("Input -> output remapping not yet supported (throws error)", async (t) => {
+ let elev = new Eleventy("./test/stubs-autocopy/", "./test/stubs-autocopy/_site12", {
+ configPath: false,
+ config: function (eleventyConfig) {
+ // not yet supported
+ eleventyConfig.addPassthroughCopy({"**/*.png": "yo"}, {
+ mode: "html-relative"
+ });
+
+ eleventyConfig.on("eleventy.passthrough", copyMap => {
+ t.deepEqual(copyMap, { map: {} })
+ });
+
+ eleventyConfig.addTemplate("test.njk", `
`)
+ },
+ });
+
+ elev.disableLogger();
+
+ await t.throwsAsync(async () => {
+ await elev.write();
+ }, {
+ message: `mode: \'html-relative\' does not yet support passthrough copy objects (input -> output mapping). Use a string glob or an Array of string globs.`
+ });
+
+ t.is(fs.existsSync("test/stubs-autocopy/_site12/test/index.html"), false);
+});
diff --git a/test/ProjectDirectoriesTest.js b/test/ProjectDirectoriesTest.js
index ff410c846..772d0b166 100644
--- a/test/ProjectDirectoriesTest.js
+++ b/test/ProjectDirectoriesTest.js
@@ -347,3 +347,26 @@ test("getLayoutPath (includes dir)", t => {
t.is(d.getLayoutPath("layout.html"), "./test/stubs/components/layout.html");
t.is(d.getLayoutPathRelativeToInputDirectory("layout.html"), "components/layout.html");
});
+
+test("isFileIn*Folder", t => {
+ let d = new ProjectDirectories();
+
+ t.is(d.isFileInProjectFolder("test.njk"), true);
+ t.is(d.isFileInProjectFolder("../test.njk"), false);
+
+ t.is(d.isFileInOutputFolder("test.njk"), false);
+ t.is(d.isFileInOutputFolder("../test.njk"), false);
+ t.is(d.isFileInOutputFolder("../_site/test.html"), false);
+ t.is(d.isFileInOutputFolder("_site/test.html"), true);
+});
+
+test("isFileInOutputFolder (change output folder)", t => {
+ let d = new ProjectDirectories();
+ d.setOutput("yolo")
+
+ t.is(d.isFileInOutputFolder("test.njk"), false);
+ t.is(d.isFileInOutputFolder("../test.njk"), false);
+ t.is(d.isFileInOutputFolder("_site/test.html"), false);
+ t.is(d.isFileInOutputFolder("../_site/test.html"), false);
+ t.is(d.isFileInOutputFolder("yolo/test.html"), true);
+});
diff --git a/test/stubs-autocopy/.gitkeep b/test/stubs-autocopy/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/stubs-autocopy/possum.jpg b/test/stubs-autocopy/possum.jpg
new file mode 100644
index 000000000..885bf7317
Binary files /dev/null and b/test/stubs-autocopy/possum.jpg differ
diff --git a/test/stubs-autocopy/possum.png b/test/stubs-autocopy/possum.png
new file mode 100644
index 000000000..f332150e7
Binary files /dev/null and b/test/stubs-autocopy/possum.png differ