diff --git a/README.md b/README.md index 30fd4f6..4696f07 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,12 @@ can also be an array. Each element is passed to the minimizer at the same index in the `minify` array. If a single object is provided instead, it is reused for every minimizer. +Two keys are filled in before a minimizer sees them, and only when the options +do not already set them: `ecma`, from +[`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment), +and `module`, from the asset's own `javascriptModule` info or its `.mjs` / +`.cjs` extension. Setting either yourself wins, including `module: false`. + > **Note** > > `terserOptions` is kept as a deprecated alias of `minimizerOptions` for @@ -626,11 +632,16 @@ module.exports = { import webp from "./image.jpg?as=webp"; ``` -Under [`cache.type: "filesystem"`](https://webpack.js.org/configuration/cache/#cachetype) -a module is restored from the pack rather than rebuilt across runs. That -restored result is the generator's, so changing `generate` or -`generatorOptions` has to invalidate the pack, and the plugin adds their -identity to +In watch mode the rename is carried on the module rather than reapplied each +build, so a rebuild that does not touch the image keeps pointing at the +generated name without running the generator again. Changing the image does +run it again, since its answer is cached under the bytes. + +The same holds across runs under +[`cache.type: "filesystem"`](https://webpack.js.org/configuration/cache/#cachetype), +where a module is restored from the pack rather than rebuilt. That restored +result is the generator's, so changing `generate` or `generatorOptions` has to +invalidate the pack, and the plugin adds their identity to [`cache.version`](https://webpack.js.org/configuration/cache/#cacheversion) so it does. This needs the plugin to be in the config — `plugins` or `optimization.minimizer` — since webpack builds the cache while it applies @@ -656,7 +667,13 @@ Default: `{}` Options for [`generate`](#generate), exactly as [`minimizerOptions`](#minimizeroptions) is for [`minify`](#minify): one object -for one generator, or an array positionally matching an array of generators. +for one generator, or an array positionally matching an array of generators. A +single object handed an array of generators is reused for every one of them. + +`ecma` is filled in from +[`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) +unless the options set it, the same way it is for +[`minimizerOptions`](#minimizeroptions). ```js const MinimizerPlugin = require("minimizer-webpack-plugin"); diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 169f84a..e8e8ce0 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -146,6 +146,109 @@ describe("generate option", () => { }); }); +describe("generatorOptions", () => { + /** + * Records the options it was handed and rewrites nothing, so a test can read + * back what reached it. + * @param {{ [file: string]: string | Buffer }} input input + * @param {undefined} sourceMap source map + * @param {Record} generatorOptions the options under test + * @returns {{ code: string | Buffer }} the input, unchanged + */ + function records(input, sourceMap, generatorOptions) { + const [[, code]] = Object.entries(input); + + records.seen.push(generatorOptions); + + return { code }; + } + + records.supportsBinary = () => true; + records.supportsWorker = () => false; + + beforeEach(() => { + records.seen = []; + }); + + /** + * @param {object} options plugin options beyond `test` and `generate` + * @param {EXPECTED_ANY} generate the generator, or an array of them + * @returns {Promise} the stats of the build + */ + async function build(options, generate) { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ test: /\.jpe?g$/i, generate, ...options }).apply( + compiler, + ); + + return compile(compiler); + } + + it("should hand one object to the generator", async () => { + const stats = await build( + { generatorOptions: { encodeOptions: { webp: { quality: 90 } } } }, + records, + ); + + if (reportedNoAwait(stats)) { + return; + } + + expect(records.seen).toHaveLength(1); + expect(records.seen[0]).toMatchObject({ + encodeOptions: { webp: { quality: 90 } }, + }); + }); + + it("should default to an empty object", async () => { + const stats = await build({}, records); + + if (reportedNoAwait(stats)) { + return; + } + + expect(records.seen).toHaveLength(1); + // `module` and `ecma` are overlaid onto a generator's options the same way + // they are onto a minimizer's, so an absent `generatorOptions` is not bare. + expect(Object.keys(records.seen[0]).sort()).toEqual(["ecma", "module"]); + }); + + it("should match an array of options to an array of generators", async () => { + const stats = await build( + { generatorOptions: [{ first: true }, { second: true }] }, + [records, records], + ); + + if (reportedNoAwait(stats)) { + return; + } + + expect(records.seen).toHaveLength(2); + expect(records.seen[0]).toMatchObject({ first: true }); + expect(records.seen[1]).toMatchObject({ second: true }); + expect(records.seen[0]).not.toHaveProperty("second"); + }); + + it("should share one object across an array of generators", async () => { + const stats = await build({ generatorOptions: { shared: true } }, [ + records, + records, + ]); + + if (reportedNoAwait(stats)) { + return; + } + + expect(records.seen).toHaveLength(2); + expect(records.seen[0]).toMatchObject({ shared: true }); + expect(records.seen[1]).toMatchObject({ shared: true }); + }); +}); + describe("sharpGenerate target format", () => { it("should report when no target format was asked for", async () => { const result = await MinimizerPlugin.sharpGenerate( diff --git a/test/generate-watch.test.js b/test/generate-watch.test.js new file mode 100644 index 0000000..8c0cc9d --- /dev/null +++ b/test/generate-watch.test.js @@ -0,0 +1,260 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import MinimizerPlugin from "../src"; +import { replaceExtension } from "../src/utils"; + +import { getCompiler, getErrors } from "./helpers"; + +// Renaming an asset needs `NormalModule`'s `processResult` hook to be able to +// await. Read off what the build did rather than off a version number: the +// release carrying it is not out yet, so a version test would claim the +// capability on every webpack released before it. +/** + * @param {import("webpack").Stats} stats stats + * @returns {boolean} true when the plugin reported that it cannot await + */ +function reportedNoAwait(stats) { + return getErrors(stats).join("\n").includes("hook can await"); +} + +/** + * A stand-in for an encoder: it prefixes the bytes and says what the result is + * now called, which is all the plugin needs to rename the asset. + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ +function toWebp(input) { + const [[name, code]] = Object.entries(input); + + toWebp.calls += 1; + + return { + code: Buffer.concat([Buffer.from("WEBP:"), Buffer.from(code)]), + filename: replaceExtension(name, "webp"), + }; +} + +toWebp.supportsBinary = () => true; +toWebp.supportsWorker = () => false; +toWebp.calls = 0; + +/** + * @param {string} directory directory to remove, with everything under it + * @returns {void} + */ +function removeRecursive(directory) { + for (const entry of fs.readdirSync(directory)) { + const full = path.join(directory, entry); + + if (fs.statSync(full).isDirectory()) { + removeRecursive(full); + } else { + fs.unlinkSync(full); + } + } + + fs.rmdirSync(directory); +} + +/** + * Drives a watching compiler one build at a time: each call resolves with the + * stats of the next build the watcher completes. + */ +class Watcher { + /** + * @param {import("webpack").Compiler} compiler compiler + */ + constructor(compiler) { + this.pending = []; + this.waiting = []; + // Polling, because the CI runners disagree about native file watching. + this.watching = compiler.watch( + { aggregateTimeout: 50, poll: 100 }, + (error, stats) => { + const settle = this.waiting.shift(); + + if (settle) { + settle(error, stats); + } else { + this.pending.push([error, stats]); + } + }, + ); + } + + /** + * @returns {Promise} the stats of the next build + */ + next() { + return new Promise((resolve, reject) => { + /** + * @param {(Error | null)=} error build error + * @param {import("webpack").Stats=} stats build stats + * @returns {void} + */ + const settle = (error, stats) => { + if (error) { + reject(error); + } else { + resolve(/** @type {import("webpack").Stats} */ (stats)); + } + }; + + const ready = this.pending.shift(); + + if (ready) { + settle(ready[0], ready[1]); + } else { + this.waiting.push(settle); + } + }); + } + + /** + * @returns {Promise} resolves once the watcher has let go of the files + */ + close() { + return new Promise((resolve) => { + this.watching.close(() => resolve()); + }); + } +} + +describe("generate option in watch mode", () => { + let context; + let watcher; + + beforeEach(() => { + toWebp.calls = 0; + context = fs.mkdtempSync(path.join(os.tmpdir(), "minimizer-watch-")); + + fs.writeFileSync( + path.join(context, "index.js"), + 'import jpg from "./image.jpg";\n\n// eslint-disable-next-line no-console\nconsole.log(jpg);\n', + ); + fs.writeFileSync(path.join(context, "image.jpg"), Buffer.from("first")); + }); + + afterEach(async () => { + if (watcher) { + await watcher.close(); + watcher = undefined; + } + + removeRecursive(context); + }); + + /** + * @returns {import("webpack").Compiler} a compiler over the temporary project + */ + function makeCompiler() { + const compiler = getCompiler({ + context, + entry: path.join(context, "index.js"), + // `production` leaves caching off, and then every watch build rebuilds + // every module — which is not what a rename has to survive. + cache: { type: "memory" }, + module: { + rules: [ + { + test: /\.jpe?g$/i, + type: "asset/resource", + generator: { filename: "[name][ext]" }, + }, + ], + }, + }); + + new MinimizerPlugin({ test: /\.jpe?g$/i, generate: toWebp }).apply( + compiler, + ); + + return compiler; + } + + /** + * @param {import("webpack").Compiler} compiler compiler + * @param {import("webpack").Stats} stats stats + * @param {string} name emitted name + * @returns {Buffer} the emitted bytes + */ + function readBytes(compiler, stats, name) { + return compiler.outputFileSystem.readFileSync( + path.join(stats.compilation.outputOptions.path, name), + ); + } + + it("should re-emit the renamed asset when the image changes", async () => { + const compiler = makeCompiler(); + + watcher = new Watcher(compiler); + + const first = await watcher.next(); + + if (reportedNoAwait(first)) { + return; + } + + expect(getErrors(first)).toEqual([]); + expect(Object.keys(first.compilation.assets)).toContain("image.webp"); + expect(readBytes(compiler, first, "image.webp").toString()).toBe( + "WEBP:first", + ); + + fs.writeFileSync(path.join(context, "image.jpg"), Buffer.from("second")); + + const second = await watcher.next(); + + expect(getErrors(second)).toEqual([]); + + const names = Object.keys(second.compilation.assets); + + expect(names).toContain("image.webp"); + expect(names).not.toContain("image.jpg"); + // The generator's answer is cached on the bytes, so new bytes have to + // reach it rather than the first build's result being served again. + expect(toWebp.calls).toBe(2); + expect(readBytes(compiler, second, "image.webp").toString()).toBe( + "WEBP:second", + ); + }); + + it("should keep the rename when only the module importing it changes", async () => { + const compiler = makeCompiler(); + + watcher = new Watcher(compiler); + + const first = await watcher.next(); + + if (reportedNoAwait(first)) { + return; + } + + expect(Object.keys(first.compilation.assets)).toContain("image.webp"); + + fs.writeFileSync( + path.join(context, "index.js"), + 'import jpg from "./image.jpg";\n\n// eslint-disable-next-line no-console\nconsole.log(jpg, "changed");\n', + ); + + const second = await watcher.next(); + + expect(getErrors(second)).toEqual([]); + + const names = Object.keys(second.compilation.assets); + + // One call across both builds is the evidence that the image module was + // not rebuilt, so the rename survived on the module rather than being + // reapplied — `buildInfo.assetResource` is what carries it. + expect(toWebp.calls).toBe(1); + expect(names).toContain("image.webp"); + expect(names).not.toContain("image.jpg"); + + const bundle = readBytes(compiler, second, "main.js").toString(); + + expect(bundle).toContain('"image.webp"'); + expect(bundle).not.toContain('"image.jpg"'); + }); +});