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