From 2d1bbe7c51f2004cdbe8d7b1575f563ce7e128a6 Mon Sep 17 00:00:00 2001 From: Bryan Friedman Date: Sun, 12 Oct 2025 23:11:28 -0700 Subject: [PATCH] Add data cascade option for htmlrelativecopy --- src/Plugins/HtmlRelativeCopyPlugin.js | 101 +++++++++++++++++- src/Util/HtmlRelativeCopy.js | 73 +++++++++++++ test/HtmlRelativeCopyTest.js | 144 ++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 3 deletions(-) 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