diff --git a/src/Plugins/HtmlRelativeCopyPlugin.js b/src/Plugins/HtmlRelativeCopyPlugin.js
index 2bbf39f6d..c7e48dfd9 100644
--- a/src/Plugins/HtmlRelativeCopyPlugin.js
+++ b/src/Plugins/HtmlRelativeCopyPlugin.js
@@ -1,6 +1,53 @@
+import fs from "node:fs";
+import path from "node:path";
import { HtmlRelativeCopy } from "../Util/HtmlRelativeCopy.js";
// https://github.com/11ty/eleventy/pull/3573
+function readJsonIfExists(fp) {
+ try {
+ if (fs.existsSync(fp)) {
+ const txt = fs.readFileSync(fp, "utf8");
+ return JSON.parse(txt);
+ }
+ } catch (e) {
+ throw e;
+ }
+}
+
+function findEleventyCopyFromDirectoryData(inputPath) {
+ // Walk up from the template's directory.
+ // At each dir, try:
+ //
/.json
+ // /.11tydata.json
+ // If any contains { eleventyCopy: ... }, collect them (nearest wins last).
+ const hits = [];
+ let dir = path.dirname(inputPath);
+
+ // Stop at filesystem root
+ while (dir && dir !== path.dirname(dir)) {
+ const base = path.basename(dir);
+ const candidates = [path.join(dir, `${base}.json`), path.join(dir, `${base}.11tydata.json`)];
+
+ for (const fp of candidates) {
+ const data = readJsonIfExists(fp);
+ if (data && Object.prototype.hasOwnProperty.call(data, "eleventyCopy")) {
+ hits.push(data.eleventyCopy);
+ }
+ }
+
+ dir = path.dirname(dir);
+ }
+
+ if (!hits.length) return undefined;
+
+ // Merge shallowly into an array
+ const out = [];
+ for (const h of hits) {
+ if (Array.isArray(h)) out.push(...h);
+ else if (typeof h === "string") out.push(h);
+ }
+ return out.length ? out : undefined;
+}
// one HtmlRelativeCopy instance per entry
function init(eleventyConfig, options) {
@@ -26,12 +73,62 @@ function init(eleventyConfig, options) {
htmlrel.addMatchingGlob(opts.match);
htmlrel.setFailOnError(opts.failOnError);
htmlrel.setCopyOptions(opts.copyOptions);
+ htmlrel.addPaths(opts.paths);
+
+ // run once per output page (the url transform fires many times per page)
+ const processedOncePerOutput = new Set();
eleventyConfig.htmlTransformer.addUrlTransform(
opts.extensions,
function (targetFilepathOrUrl) {
// @ts-ignore
- htmlrel.copy(targetFilepathOrUrl, this.page.inputPath, this.page.outputPath);
+ const pageInput = this?.page?.inputPath;
+ // @ts-ignore
+ const pageOutput = this?.page?.outputPath;
+
+ // Regular html-relative behavior
+ try {
+ // @ts-ignore
+ htmlrel.copy(targetFilepathOrUrl, pageInput, pageOutput);
+ } catch (e) {
+ if (opts.failOnError) throw e;
+ }
+
+ // Data-cascade: run exactly once per output page
+ try {
+ const outKey = pageOutput || this?.page?.url || "";
+ if (!outKey || processedOncePerOutput.has(outKey)) {
+ return targetFilepathOrUrl;
+ }
+ processedOncePerOutput.add(outKey);
+
+ // Try a few places for eleventyCopy on the transform context (sometimes present)
+ const fromContext =
+ // @ts-ignore
+ this?.eleventyCopy ||
+ // @ts-ignore
+ this?.page?.eleventyCopy ||
+ // @ts-ignore
+ this?.eleventy?.data?.eleventyCopy ||
+ // @ts-ignore
+ this?.page?.data?.eleventyCopy;
+
+ let globs = fromContext;
+ if (!globs) {
+ // Fallback: read directory data files ourselves (e.g., blog/blog.json)
+ globs = findEleventyCopyFromDirectoryData(pageInput);
+ }
+
+ if (globs) {
+ try {
+ htmlrel.copyFromDataCascade(globs, pageInput, pageOutput);
+ } catch (innerErr) {
+ if (opts.failOnError) throw innerErr;
+ }
+ }
+ } catch (outerErr) {
+ if (opts.failOnError) throw outerErr;
+ }
// TODO front matter option for manual copy
return targetFilepathOrUrl;
@@ -42,8 +139,6 @@ function init(eleventyConfig, options) {
priority: -1,
},
);
-
- htmlrel.addPaths(opts.paths);
}
function HtmlRelativeCopyPlugin(eleventyConfig) {
diff --git a/src/Util/HtmlRelativeCopy.js b/src/Util/HtmlRelativeCopy.js
index fe2ebe1a2..fb1438c34 100644
--- a/src/Util/HtmlRelativeCopy.js
+++ b/src/Util/HtmlRelativeCopy.js
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import { TemplatePath } from "@11ty/eleventy-utils";
import isValidUrl from "../Util/ValidUrl.js";
@@ -105,6 +106,78 @@ class HtmlRelativeCopy {
return TemplatePath.join(dir, ref);
}
+ /**
+ * Recursively walk a directory and collect file paths matching a pattern.
+ * This is a simple manual glob matcher (supports *, **, and ? via isGlobMatch).
+ */
+ #walkAndCollect(dir, pattern, results, dotOpt) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ // Skip dotfiles if dotOpt=false
+ if (!dotOpt && entry.name.startsWith(".")) {
+ continue;
+ }
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ this.#walkAndCollect(full, pattern, results, dotOpt);
+ } else if (isGlobMatch(full, [pattern])) {
+ results.push(full);
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Data Cascade Option: copies files declared via `eleventyCopy`
+ */
+ copyFromDataCascade(globs, tmplInputPath, tmplOutputPath) {
+ if (!globs) return;
+
+ const list = Array.isArray(globs) ? globs : [globs];
+ if (list.length === 0) return;
+
+ const inputDir = TemplatePath.getDirFromFilePath(tmplInputPath);
+ const outDir = TemplatePath.getDirFromFilePath(tmplOutputPath);
+ const dotOpt = !!this.#copyOptions.dot;
+
+ for (const g of list) {
+ if (!g || typeof g !== "string") continue;
+
+ const pattern = TemplatePath.join(inputDir, g);
+
+ // Gather matching files manually
+ let matches = [];
+ const rootDir = fs.existsSync(inputDir) ? inputDir : process.cwd();
+ try {
+ this.#walkAndCollect(rootDir, pattern, matches, dotOpt);
+ } catch (e) {
+ if (this.#failOnError) throw e;
+ continue;
+ }
+
+ for (const source of matches) {
+ if (!this.exists(source)) {
+ if (this.#failOnError) {
+ throw new Error(
+ "Missing input file for `html-relative` Data Cascade Copy: " +
+ TemplatePath.absolutePath(source),
+ );
+ }
+ continue;
+ }
+
+ const ref = path.relative(inputDir, source);
+ const target = TemplatePath.join(outDir, ref);
+
+ this.#userConfig.emit("eleventy#copy", {
+ source,
+ target,
+ options: this.#copyOptions,
+ });
+ }
+ }
+ }
+
copy(fileRef, tmplInputPath, tmplOutputPath) {
// original ref is a full URL or no globs exist
if (this.isSkippableHref(fileRef)) {
diff --git a/test/HtmlRelativeCopyTest.js b/test/HtmlRelativeCopyTest.js
index cc9fddd05..d92410467 100644
--- a/test/HtmlRelativeCopyTest.js
+++ b/test/HtmlRelativeCopyTest.js
@@ -1,5 +1,6 @@
import test from "ava";
import fs from "node:fs";
+import path from "node:path";
import { rimrafSync } from "rimraf";
import { TemplatePath } from "@11ty/eleventy-utils";
@@ -619,3 +620,146 @@ test("Invalid copy mode throws error", async (t) => {
t.is(fs.existsSync("test/stubs-autocopy/_site13/test/index.html"), false);
});
+
+test("HTML Relative Data Cascade Copy(dir data in same folder): copies *.png without HTML reference", async (t) => {
+ const base = "test/stubs-datacascade/case1";
+ const inputDir = path.join(base, "input");
+ const outputDir = path.join(base, "_site");
+
+ fs.mkdirSync(inputDir, { recursive: true });
+ fs.copyFileSync("test/stubs-autocopy/possum.png", path.join(inputDir, "dc1.png"));
+
+ fs.writeFileSync(
+ path.join(inputDir, "input.json"),
+ JSON.stringify({ eleventyCopy: ["*.png"] }, null, 2)
+ );
+
+ const elev = new Eleventy(inputDir, outputDir, {
+ configPath: false,
+ config(eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.{png,jpg}", { mode: "html-relative" });
+ eleventyConfig.addTemplate("index.njk", `noop`);
+ },
+ });
+ elev.disableLogger();
+
+ const [copy, templates] = await elev.write();
+
+ t.is(templates.length, 1);
+ t.is(templates[0].url, "/");
+ t.is(templates[0].content, `noop`);
+ t.is(templates[0].rawInput, `noop`);
+ t.true(templates[0].inputPath.endsWith("/test/stubs-datacascade/case1/input/index.njk"));
+ t.true(
+ templates[0].outputPath.endsWith("/test/stubs-datacascade/case1/_site/index.html") ||
+ templates[0].outputPath.endsWith("/test/stubs-datacascade/case1/_site/index/index.html")
+ );
+
+ const flatImg = path.join(outputDir, "dc1.png");
+ const nestedImg = path.join(outputDir, "index", "dc1.png");
+ t.true(fs.existsSync(flatImg) || fs.existsSync(nestedImg));
+
+ const flatHtml = path.join(outputDir, "index.html");
+ const nestedHtml = path.join(outputDir, "index", "index.html");
+ t.true(fs.existsSync(flatHtml) || fs.existsSync(nestedHtml));
+});
+
+test.after.always("cleanup dirs (dc case1)", () => {
+ rimrafSync("test/stubs-datacascade/case1");
+});
+
+test("HTML Relative Data Cascade Copy (nearest dir data): copies from post/post.json", async (t) => {
+ const base = "test/stubs-datacascade/case2";
+ const inputDir = path.join(base, "input");
+ const outputDir = path.join(base, "_site");
+ const postDir = path.join(inputDir, "blogdc", "post");
+
+ fs.mkdirSync(path.join(postDir, "images"), { recursive: true });
+ fs.copyFileSync("test/stubs-autocopy/possum.png", path.join(postDir, "images", "foo.png"));
+ fs.writeFileSync(
+ path.join(postDir, "post.json"),
+ JSON.stringify({ eleventyCopy: ["images/**/*.png"] }, null, 2)
+ );
+
+ const elev = new Eleventy(inputDir, outputDir, {
+ configPath: false,
+ config(eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.{png,jpg}", { mode: "html-relative" });
+ // ensure transform runs
+ eleventyConfig.addTemplate("blogdc/post/index.njk", `noop`);
+ },
+ });
+ elev.disableLogger();
+
+ const [copy, templates] = await elev.write();
+
+ t.is(templates.length, 1);
+ t.is(templates[0].url, "/blogdc/post/");
+ t.is(templates[0].content, `noop`);
+ t.is(templates[0].rawInput, `noop`);
+ t.true(templates[0].inputPath.endsWith("/test/stubs-datacascade/case2/input/blogdc/post/index.njk"));
+ t.true(templates[0].outputPath.endsWith("/test/stubs-datacascade/case2/_site/blogdc/post/index.html"));
+
+ const expectedImg = path.join(outputDir, "blogdc", "post", "images", "foo.png");
+ t.true(fs.existsSync(expectedImg));
+
+ const expectedHtml = path.join(outputDir, "blogdc", "post", "index.html");
+ t.true(fs.existsSync(expectedHtml));
+});
+
+test.after.always("cleanup dirs (dc case2)", () => {
+ rimrafSync("test/stubs-datacascade/case2");
+});
+
+test("HTML Relative Data Cascade Copy (dir data): brace expansion matches multiple extensions", async (t) => {
+ const base = "test/stubs-datacascade/case3";
+ const inputDir = path.join(base, "input");
+ const outputDir = path.join(base, "_site");
+
+ fs.mkdirSync(inputDir, { recursive: true });
+ fs.copyFileSync("test/stubs-autocopy/possum.png", path.join(inputDir, "dc3.png"));
+ fs.copyFileSync("test/stubs-autocopy/possum.jpg", path.join(inputDir, "dc3.jpg"));
+
+ fs.writeFileSync(
+ path.join(inputDir, "input.json"),
+ JSON.stringify({ eleventyCopy: ["*.{png,jpg}"] }, null, 2)
+ );
+
+ const elev = new Eleventy(inputDir, outputDir, {
+ configPath: false,
+ config(eleventyConfig) {
+ eleventyConfig.addPassthroughCopy("**/*.{png,jpg}", { mode: "html-relative" });
+ // ensure transform runs
+ eleventyConfig.addTemplate("index.njk", `noop`);
+ },
+ });
+ elev.disableLogger();
+
+ const [copy, templates] = await elev.write();
+
+ t.is(templates.length, 1);
+ t.is(templates[0].url, "/");
+ t.is(templates[0].content, `noop`);
+ t.is(templates[0].rawInput, `noop`);
+ t.true(templates[0].inputPath.endsWith("/test/stubs-datacascade/case3/input/index.njk"));
+ t.true(
+ templates[0].outputPath.endsWith("/test/stubs-datacascade/case3/_site/index.html") ||
+ templates[0].outputPath.endsWith("/test/stubs-datacascade/case3/_site/index/index.html")
+ );
+
+ const flatPng = path.join(outputDir, "dc3.png");
+ const nestedPng = path.join(outputDir, "index", "dc3.png");
+ const flatJpg = path.join(outputDir, "dc3.jpg");
+ const nestedJpg = path.join(outputDir, "index", "dc3.jpg");
+
+ t.true(fs.existsSync(flatPng) || fs.existsSync(nestedPng));
+ t.true(fs.existsSync(flatJpg) || fs.existsSync(nestedJpg));
+
+ const flatHtml = path.join(outputDir, "index.html");
+ const nestedHtml = path.join(outputDir, "index", "index.html");
+ t.true(fs.existsSync(flatHtml) || fs.existsSync(nestedHtml));
+});
+
+test.after.always("cleanup dirs (dc case3)", () => {
+ rimrafSync("test/stubs-datacascade/case3");
+});
\ No newline at end of file