From 52e2cf598211458134549ad107f0b879ef20b795 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 14 Sep 2026 21:28:43 +0000 Subject: [PATCH 01/17] feat: let a generator say what is worth writing, and minify nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compressing an asset is not always worth a second file, so an `asset` generator now takes the three things that decide it. `threshold` skips an asset too small to bother with, before the generator is even asked. `minRatio` drops a result that is not enough smaller than what it read, since a file that saves nothing still costs a request. `relatedName` records the new file under that key on the one it came from — which is how a server asked for the original finds it — and declines an asset already carrying it. `minify` takes `false`, for an instance whose whole job is its generator. That also settles what `test` defaults to: the `.js` default belongs to minifying, and applying it with nothing to minify hid every image from the generator, so it is not applied then. Two more that follow from the same work. The generated file inherits the original's `immutable` only where the name it was given still derives from the original's, since that is what carried the hash the promise rests on. And the generator tap asks for `additionalAssets`, reading what the hook hands it rather than the whole compilation, so a file another plugin emits late still gets the one that belongs beside it. --- .changeset/generator-compression-options.md | 5 + src/index.js | 89 +++++- src/options.json | 48 +++ test/generate-option.test.js | 319 ++++++++++++++++++++ types/index.d.ts | 7 +- 5 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 .changeset/generator-compression-options.md diff --git a/.changeset/generator-compression-options.md b/.changeset/generator-compression-options.md new file mode 100644 index 00000000..aea8dcfc --- /dev/null +++ b/.changeset/generator-compression-options.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and let `minify` be `false`. diff --git a/src/index.js b/src/index.js index 7f7afaca..7a6485f7 100644 --- a/src/index.js +++ b/src/index.js @@ -218,7 +218,7 @@ const { /** * @template T - * @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation | undefined, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined } : { minify: MinimizerImplementation, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined }} DefinedDefaultMinimizerAndOptions + * @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation | false | undefined, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined } : { minify: MinimizerImplementation | false, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined }} DefinedDefaultMinimizerAndOptions */ /** @@ -336,7 +336,7 @@ class MinimizerPlugin { ), minimizerOptions, terserOptions, - test = /\.[cm]?js(\?.*)?$/i, + test: declaredTest, extractComments = true, parallel = true, include, @@ -345,6 +345,19 @@ class MinimizerPlugin { generatorOptions, } = this.rawOptions; + // `false` is no minimizer rather than a missing one, and an empty list is + // how the rest of this file reads that: every pass over them does nothing. + const minimizers = minify === false ? [] : minify; + // The JavaScript default belongs to minifying, not to the plugin: an + // instance that only generates reads whatever its generator takes, and a + // default naming `.js` would hide every image from it. + const test = + typeof declaredTest !== "undefined" + ? declaredTest + : minify === false + ? undefined + : /\.[cm]?js(\?.*)?$/i; + // `terserOptions` is a deprecated alias of `minimizerOptions`; prefer the // new name when both are provided. const resolvedMinimizerOptions = @@ -367,7 +380,7 @@ class MinimizerPlugin { exclude, minimizer: /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }} */ - (normalizeMinimizers(minify, resolvedMinimizerOptions)), + (normalizeMinimizers(minimizers, resolvedMinimizerOptions)), // Absent unless asked for: it runs while modules build, where the plugin // otherwise does nothing. generator: generate @@ -1288,7 +1301,7 @@ class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ describeGenerator(name, entry, declared) { const descriptor = isDescriptor(entry) ? entry : undefined; @@ -1306,6 +1319,9 @@ class MinimizerPlugin { deleteOriginalAssets: descriptor ? descriptor.deleteOriginalAssets : undefined, + threshold: descriptor ? descriptor.threshold : undefined, + minRatio: descriptor ? descriptor.minRatio : undefined, + relatedName: descriptor ? descriptor.relatedName : undefined, }; } @@ -1501,6 +1517,19 @@ class MinimizerPlugin { // `buffer()` rather than `source()`, which answers a source holding text // and bytes at once with a string: every byte over 0x7f is lost in that. const input = source.buffer(); + const says = /** @type {Record} */ (info); + + // Too small to be worth a second file, and one already recorded under this + // generator's key has been through it. + if ( + input.length < (generator.threshold || 0) || + (generator.relatedName && + says.related && + says.related[generator.relatedName]) + ) { + return; + } + // The generator is in the item's name rather than its etag: two presets // reading the same asset must not answer for one another. const cacheItem = cache.getItemCache( @@ -1610,8 +1639,16 @@ class MinimizerPlugin { return; } const generatedSource = output.source; - // The derived name carries the original's hash, so what the original - // promised about its own name still holds; its sourcemap does not follow. + + // Not enough smaller to be worth serving: a second file that saves nothing + // still costs a request and a place in the cache. + if ( + typeof generator.minRatio === "number" && + generatedSource.size() / input.length > generator.minRatio + ) { + return; + } + const generatedInfo = { ...info }; // The name this generator works under, which is `generated` where it says @@ -1622,6 +1659,18 @@ class MinimizerPlugin { delete generatedInfo.related; + // Only where the name it was given still derives from the original's, which + // is what carried the hash the original's immutability rests on. + if ( + info.immutable && + !( + typeof generator.filename === "string" && + /(\[name]|\[base]|\[file])/.test(generator.filename) + ) + ) { + delete generatedInfo.immutable; + } + if (compilation.getAsset(generatedName)) { compilation.updateAsset(generatedName, generatedSource, generatedInfo); @@ -1630,6 +1679,14 @@ class MinimizerPlugin { compilation.emitAsset(generatedName, generatedSource, generatedInfo); + // Recorded on the asset it was read from, which is how a server asked for + // that one finds this one. + if (generator.relatedName) { + compilation.updateAsset(name, source, { + related: { [generator.relatedName]: generatedName }, + }); + } + if (generator.deleteOriginalAssets && compilation.getAsset(name)) { compilation.deleteAsset(name); } @@ -1643,9 +1700,10 @@ class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {ReturnType} generators the generators running at this stage + * @param {Record} assets the assets this pass was handed * @returns {Promise} */ - async generateAssets(compiler, compilation, generators) { + async generateAssets(compiler, compilation, generators, assets) { const cache = compilation.getCache("TerserWebpackPlugin|generateAssets"); const scheduled = []; // Every name this plugin's generators work under, so none of them reads a @@ -1661,7 +1719,7 @@ class MinimizerPlugin { } } - for (const name of Object.keys(compilation.assets)) { + for (const name of Object.keys(assets)) { const asset = compilation.getAsset(name); if (!asset || !this.matchesName(compiler, name)) { @@ -2092,6 +2150,16 @@ class MinimizerPlugin { if (typeof one.deleteOriginalAssets !== "undefined") { misplaced.push("deleteOriginalAssets"); } + + for (const field of ["threshold", "minRatio", "relatedName"]) { + if ( + typeof ( + /** @type {Record} */ (one)[field] + ) !== "undefined" + ) { + misplaced.push(field); + } + } } if (misplaced.length > 0) { @@ -2333,8 +2401,9 @@ class MinimizerPlugin { for (const [at, generators] of generatorsByStage) { compilation.hooks.processAssets.tapPromise( - { name: pluginName, stage: at }, - () => this.generateAssets(compiler, compilation, generators), + { name: pluginName, stage: at, additionalAssets: true }, + (assets) => + this.generateAssets(compiler, compilation, generators, assets), ); } diff --git a/src/options.json b/src/options.json index c91fd78b..4e14291c 100644 --- a/src/options.json +++ b/src/options.json @@ -189,6 +189,10 @@ "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included.", "link": "https://github.com/webpack/minimizer-webpack-plugin#number", "anyOf": [ + { + "description": "Nothing minifies. For a plugin instance that only runs a `generate`.", + "enum": [false] + }, { "instanceof": "Function" }, @@ -288,6 +292,28 @@ "deleteOriginalAssets": { "description": "Removes the asset generated from. `asset` generators only.", "type": "boolean" + }, + "threshold": { + "description": "Generate only from assets larger than this, in bytes. `asset` generators only.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the generated asset only when it is this much smaller than the one it was read from, as `generated size / original size`. `asset` generators only.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the generated asset is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. `asset` generators only.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] } }, "required": ["implementation"] @@ -336,6 +362,28 @@ "deleteOriginalAssets": { "description": "Removes the asset generated from. `asset` generators only.", "type": "boolean" + }, + "threshold": { + "description": "Generate only from assets larger than this, in bytes. `asset` generators only.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the generated asset only when it is this much smaller than the one it was read from, as `generated size / original size`. `asset` generators only.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the generated asset is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. `asset` generators only.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] } }, "required": ["implementation"] diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 9e2668ff..c1425caa 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1786,6 +1786,325 @@ describe("replaceExtension", () => { }); }); +describe("generate with nothing to minify", () => { + /** + * @returns {EXPECTED_ANY} a generator that hands back what it read + */ + const copier = () => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the same bytes + */ + const copy = (input) => { + const [[name, code]] = Object.entries(input); + + copy.saw.push(name); + + return { code: Buffer.isBuffer(code) ? code : Buffer.from(code) }; + }; + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + copy.saw = []; + + return copy; + }; + + it("should reach every asset when `minify` is false and `test` is not set", async () => { + const copy = copier(); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + minify: false, + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // The `.js` default belongs to minifying: with nothing minifying it would + // hide every image from the generator, which is the whole job here. + expect(copy.saw).toContain("image.png"); + expect(copy.saw).toContain("image.svg"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should still honour a `test` that is set", async () => { + const copy = copier(); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + minify: false, + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(copy.saw).toEqual(["image.png"]); + expect(getErrors(stats)).toEqual([]); + }); + + it("should minify nothing when `minify` is false", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ minify: false }).apply(compiler); + + const stats = await compile(compiler); + const bundle = readAsset("main.js", compiler, stats); + + // Left as webpack rendered it: no minimizer ran, and the asset says so. + expect(bundle).toContain("\n"); + expect( + stats.compilation.getAsset("main.js").info.minimized, + ).toBeUndefined(); + expect(getErrors(stats)).toEqual([]); + }); +}); + +describe("generate from an asset emitted late", () => { + it("should generate from an asset added after the generators ran", async () => { + const seen = []; + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the same bytes + */ + const copy = (input) => { + const [[name, code]] = Object.entries(input); + + seen.push(name); + + return { code: Buffer.isBuffer(code) ? code : Buffer.from(code) }; + }; + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + + class EmitLate { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + const { RawSource } = inner.webpack.sources; + + inner.hooks.compilation.tap("EmitLate", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "EmitLate", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + compilation.emitAsset("late.txt", new RawSource("late bytes")); + }, + ); + }); + } + } + + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new EmitLate().apply(compiler); + new MinimizerPlugin({ + test: /\.txt$/i, + minify: false, + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // The tap is re-invoked for what arrives after it first ran, so a file + // another plugin adds late still gets the one that belongs beside it. + expect(seen).toContain("late.txt"); + expect(Object.keys(stats.compilation.assets)).toContain("late.copy.txt"); + expect(getErrors(stats)).toEqual([]); + }); +}); + +describe("generate assets, what is worth writing", () => { + /** + * A generator that pads or shrinks what it read, so `threshold` and + * `minRatio` can be driven from a known size. + * @param {number} factor how much of the input to hand back + * @returns {EXPECTED_ANY} the generator + */ + const scaleBy = (factor) => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the scaled result + */ + const scale = (input) => { + const [[, code]] = Object.entries(input); + const bytes = Buffer.isBuffer(code) ? code : Buffer.from(code); + + scale.calls += 1; + + return { + code: + factor <= 1 + ? bytes.subarray(0, Math.ceil(bytes.length * factor)) + : Buffer.concat([bytes, Buffer.alloc(bytes.length * (factor - 1))]), + }; + }; + + scale.supportsBinary = () => true; + scale.supportsWorker = () => false; + scale.calls = 0; + + return scale; + }; + + /** + * @param {object} descriptor extra generator descriptor keys + * @param {EXPECTED_ANY} implementation the generator + * @returns {Promise} what the build produced + */ + const build = async (descriptor, implementation) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + minify: false, + generate: { + implementation, + type: "asset", + filename: "[path][name].copy[ext]", + ...descriptor, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + return { compiler, stats, assets: Object.keys(stats.compilation.assets) }; + }; + + it("should skip an asset smaller than `threshold`", async () => { + const scale = scaleBy(1); + const { stats, assets } = await build({ threshold: 1024 * 1024 }, scale); + + // Nothing is even read: the size is known before the generator runs. + expect(scale.calls).toBe(0); + expect(assets).not.toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should generate from an asset larger than `threshold`", async () => { + const scale = scaleBy(1); + const { stats, assets } = await build({ threshold: 1024 }, scale); + + expect(scale.calls).toBe(1); + expect(assets).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should drop a result that is not `minRatio` smaller", async () => { + const scale = scaleBy(2); + const { stats, assets } = await build({ minRatio: 0.8 }, scale); + + // It ran and its answer was twice the size, so keeping it would cost a + // request to serve more bytes than the file it came from. + expect(scale.calls).toBe(1); + expect(assets).not.toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should keep a result that is `minRatio` smaller", async () => { + const scale = scaleBy(0.5); + const { stats, assets } = await build({ minRatio: 0.8 }, scale); + + expect(assets).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should record the generated asset under `relatedName`", async () => { + const { stats } = await build({ relatedName: "copied" }, scaleBy(1)); + + // The original points at it, which is how a server asked for the original + // finds the file beside it. + expect(stats.compilation.getAsset("image.png").info.related.copied).toBe( + "image.copy.png", + ); + expect(getErrors(stats)).toEqual([]); + }); + + it("should leave an asset already carrying that key alone", async () => { + const scale = scaleBy(1); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + class AlreadyCopied { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + inner.hooks.compilation.tap("AlreadyCopied", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyCopied", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + (assets) => { + for (const name of Object.keys(assets)) { + if (/\.png$/i.test(name)) { + compilation.updateAsset(name, (one) => one, { + related: { copied: "elsewhere.png" }, + }); + } + } + }, + ); + }); + } + } + + new AlreadyCopied().apply(compiler); + new MinimizerPlugin({ + test: /\.png$/i, + minify: false, + generate: { + implementation: scale, + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(scale.calls).toBe(0); + expect(getErrors(stats)).toEqual([]); + }); +}); + describe("generate assets, byte for byte", () => { const PREFIX = "/* prepended */"; diff --git a/types/index.d.ts b/types/index.d.ts index 21f93ec3..36bd0672 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -96,7 +96,7 @@ declare class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ private describeGenerator; /** @@ -167,6 +167,7 @@ declare class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {ReturnType} generators the generators running at this stage + * @param {Record} assets the assets this pass was handed * @returns {Promise} */ private generateAssets; @@ -662,12 +663,12 @@ type BasePluginOptions = { type DefinedDefaultMinimizerAndOptions = T extends import("terser").MinifyOptions ? { - minify?: MinimizerImplementation | undefined; + minify?: MinimizerImplementation | false | undefined; minimizerOptions?: MinimizerOptions | undefined; terserOptions?: MinimizerOptions | undefined; } : { - minify: MinimizerImplementation; + minify: MinimizerImplementation | false; minimizerOptions?: MinimizerOptions | undefined; terserOptions?: MinimizerOptions | undefined; }; From c83fa0e3b4209105660fa1a8de4467e867337754 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 14 Sep 2026 21:59:10 +0000 Subject: [PATCH 02/17] fix: do not salt the chunk hash when nothing minifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `minify: false` stopped the minimizing but not the tap that varies every chunk's hash on what the minimizers are — so adding a generator-only instance renamed every file a user serves, which is the one thing a plugin that minifies nothing has no business doing. The salt and the embedded-source hooks now go up only where something minifies. Found by rewiring `compression-webpack-plugin` onto this: its suite failed on a `[fullhash]` that moved, and the hash a build is named by is not something to notice from a diff. --- src/index.js | 28 +++++++++++++++++++---- test/generate-option.test.js | 43 ++++++++++++++++++++++++++++++++++++ types/index.d.ts | 8 +++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index 7a6485f7..c68386cc 100644 --- a/src/index.js +++ b/src/index.js @@ -1419,6 +1419,17 @@ class MinimizerPlugin { return this.generators().some((one) => one.type !== "asset"); } + /** + * The minimizers as a list, whichever shape they were written in. Empty is + * `minify: false`, which is what every pass over them then does nothing for. + * @private + * @param {EXPECTED_ANY} implementation one implementation, or an array + * @returns {EXPECTED_ANY[]} them + */ + minimizerImplementations(implementation) { + return Array.isArray(implementation) ? implementation : [implementation]; + } + /** * Every name the functions this plugin runs mark an asset with, which is * what stats have to know how to print. @@ -2276,12 +2287,20 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); + // Nothing minifies, so nothing it could do varies the bundle: salting the + // hash anyway would rename every file a generator-only instance touches. + const minifies = + this.minimizerImplementations(this.options.minimizer.implementation) + .length > 0; + // The salt is the name this plugin shipped under, and every `[contenthash]` // is taken over it: renaming it would rename every file a user serves. - hooks.chunkHash.tap(pluginName, (chunk, hash) => { - hash.update("TerserPlugin"); - hash.update(data); - }); + if (minifies) { + hooks.chunkHash.tap(pluginName, (chunk, hash) => { + hash.update("TerserPlugin"); + hash.update(data); + }); + } // Added in webpack 5.110: source one language embeds in another, which no // asset carries and `processAssets` therefore never sees. @@ -2290,6 +2309,7 @@ class MinimizerPlugin { (/** @type {unknown} */ (compilation.hooks)); if ( + minifies && embeddedHooks.renderEmbeddedSource && embeddedHooks.embeddedSourceHash ) { diff --git a/test/generate-option.test.js b/test/generate-option.test.js index c1425caa..c927f427 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1858,6 +1858,49 @@ describe("generate with nothing to minify", () => { expect(getErrors(stats)).toEqual([]); }); + it("should not change what the build is named when it minifies nothing", async () => { + /** + * @param {boolean} withPlugin whether to apply the plugin + * @returns {Promise} the emitted names + */ + const namesFrom = async (withPlugin) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].js?ver=[fullhash]", + }, + module: { rules: IMAGE_RULES }, + }); + + if (withPlugin) { + new MinimizerPlugin({ + minify: false, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.includes(".js?ver=")) + .sort(); + }; + + const without = await namesFrom(false); + const with_ = await namesFrom(true); + + // The plugin salts the chunk hash with what its minimizers are, and with + // none there is nothing to vary: adding it must not rename a user's files. + expect(with_.filter((name) => !name.includes(".copy."))).toEqual(without); + }); + it("should minify nothing when `minify` is false", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), diff --git a/types/index.d.ts b/types/index.d.ts index 36bd0672..dc33673f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -124,6 +124,14 @@ declare class MinimizerPlugin { * @returns {boolean} true when one does */ private hasModuleGenerator; + /** + * The minimizers as a list, whichever shape they were written in. Empty is + * `minify: false`, which is what every pass over them then does nothing for. + * @private + * @param {EXPECTED_ANY} implementation one implementation, or an array + * @returns {EXPECTED_ANY[]} them + */ + private minimizerImplementations; /** * Every name the functions this plugin runs mark an asset with, which is * what stats have to know how to print. From 2738ab5d45769dd5f5d98f9af222150fd00288d4 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 14 Sep 2026 22:02:53 +0000 Subject: [PATCH 03/17] test: record what `minify` now accepts in the validation snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `false` joins the list the schema renders when `minify` is given something else, so the message that lists it moved. The Node 20+ rows run the whole suite with snapshots enforced — the older rows pass `-u` and would never have caught it, which is how it reached CI. --- test/__snapshots__/validate-options.test.js.snap | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index 84d09f98..db5984b4 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,10 +132,12 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } + false | function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: + * options.minify should be false. + -> Nothing minifies. For a plugin instance that only runs a \`generate\`. * options.minify should be an instance of function. * options.minify should be an array: [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) From 4139a06c233735d0e8476f62888df2dae8ab4a8c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 14 Sep 2026 22:09:43 +0000 Subject: [PATCH 04/17] test: cover the two lines this change left unrun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch coverage named both, and both are worth saying out loud rather than reaching by accident. A generated file stops claiming `immutable` when the name it was given carries none of the original's — the promise rested on a hash that is no longer in the name — and the three compression fields are refused on an `import` generator, which writes no second file for them to describe. --- test/generate-option.test.js | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/generate-option.test.js b/test/generate-option.test.js index c927f427..ed6ce8c7 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1287,6 +1287,27 @@ describe("generate options", () => { ).toThrow(/`filter` and `deleteOriginalAssets` in `generate`'s 'webp'/); }); + it("should reject the compression fields on an `import` generator", () => { + const webp = encoderNamed("WEBP"); + + // They describe a second file being worth writing, and an `import` + // generator writes none — it re-encodes the module it was asked for. + expect(() => + construct({ + generate: { + webp: { + implementation: webp, + threshold: 1024, + minRatio: 0.8, + relatedName: "gzipped", + }, + }, + }), + ).toThrow( + /`threshold` and `minRatio` and `relatedName` in `generate`'s 'webp'/, + ); + }); + it("should reject options given in both places for one generator", () => { const webp = encoderNamed("WEBP"); @@ -1786,6 +1807,65 @@ describe("replaceExtension", () => { }); }); +describe("what a generated file promises about its name", () => { + /** + * @param {string} filename the generator's filename template + * @returns {Promise} what the generated file says + */ + const infoFor = async (filename) => { + const copy = (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }); + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[contenthash].js", + }, + module: { + rules: [ + { + test: /\.(png|jpe?g|svg|webp)/i, + type: "asset/resource", + generator: { filename: "[name].[contenthash][ext]" }, + }, + ], + }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + minify: false, + generate: { implementation: copy, type: "asset", filename }, + }).apply(compiler); + + const stats = await compile(compiler); + const generated = Object.keys(stats.compilation.assets).find((name) => + name.includes(".copy"), + ); + + return stats.compilation.getAsset(generated).info; + }; + + it("should stay immutable where the name still carries the original's", async () => { + const info = await infoFor("[path][name].copy[ext]"); + + expect(info.immutable).toBe(true); + }); + + it("should not claim immutable where the name does not", async () => { + const info = await infoFor("fixed.copy.png"); + + // The original's promise rested on a hash in its name; a fixed name + // carries none, so the file behind it can change. + expect(info.immutable).toBeUndefined(); + }); +}); + describe("generate with nothing to minify", () => { /** * @returns {EXPECTED_ANY} a generator that hands back what it read From 062fd5fe65bda02d229301f5c22c990546a2d300 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 15 Sep 2026 12:22:28 +0000 Subject: [PATCH 05/17] feat: let a minimizer write beside the asset it read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compressing an asset is minifying it into a second file, so it belongs in `minify` rather than in a generator: a `minify` descriptor naming a `filename` writes its result there instead of over the asset, with `threshold`, `minRatio`, `relatedName` and `deleteOriginalAssets` deciding whether the file is worth writing at all. Nothing has to say `minify: false` any more, so it no longer takes it. The default minimizer is what an instance given nothing else to do gets, and an instance configured to generate reads whatever its generator takes — the `.js` default belongs to that minifier, not to the plugin. One that only writes beside replaces no asset, so it no longer salts the chunk hash, and it runs where a generator does rather than in the pass that would have replaced its asset. --- .changeset/generator-compression-options.md | 2 +- src/index.js | 146 +++++++++++++++--- src/options.json | 66 +++++++- src/utils.js | 47 +++++- .../validate-options.test.js.snap | 28 +++- test/generate-option.test.js | 19 +-- test/stage-option.test.js | 124 +++++++++++++++ test/validate-options.test.js | 23 +++ types/index.d.ts | 20 ++- types/utils.d.ts | 47 +++++- 10 files changed, 472 insertions(+), 50 deletions(-) diff --git a/.changeset/generator-compression-options.md b/.changeset/generator-compression-options.md index aea8dcfc..ce27c849 100644 --- a/.changeset/generator-compression-options.md +++ b/.changeset/generator-compression-options.md @@ -2,4 +2,4 @@ "minimizer-webpack-plugin": minor --- -Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and let `minify` be `false`. +Let a `minify` minimizer write beside the asset it read, with `filename`, `threshold`, `minRatio`, `relatedName` and `deleteOriginalAssets`. diff --git a/src/index.js b/src/index.js index c68386cc..bc506cb9 100644 --- a/src/index.js +++ b/src/index.js @@ -47,6 +47,7 @@ const { /** @typedef {import("jest-worker").Worker} JestWorker */ /** @typedef {import("@jridgewell/trace-mapping").EncodedSourceMap & { sources: string[], sourcesContent?: string[], file: string }} RawSourceMap */ /** @typedef {import("@jridgewell/trace-mapping").TraceMap} TraceMap */ +/** @typedef {import("./utils").MinimizerSidecar} MinimizerSidecar */ /** @typedef {RegExp | string} Rule */ /** @typedef {Rule[] | Rule} Rules */ @@ -223,7 +224,7 @@ const { /** * @template T - * @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }, generator?: { implementation: MinimizerImplementation, options: MinimizerOptions } }} InternalPluginOptions + * @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }, generator?: { implementation: MinimizerImplementation, options: MinimizerOptions } }} InternalPluginOptions */ /** @@ -331,9 +332,7 @@ class MinimizerPlugin { // TODO handle json and etc in the next major release // TODO make `minimizer` option instead `minify` and `terserOptions` in the next major release, also rename `terserMinify` to `terserMinimize` const { - minify = /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) - ), + minify: declaredMinify, minimizerOptions, terserOptions, test: declaredTest, @@ -345,16 +344,24 @@ class MinimizerPlugin { generatorOptions, } = this.rawOptions; - // `false` is no minimizer rather than a missing one, and an empty list is - // how the rest of this file reads that: every pass over them does nothing. - const minimizers = minify === false ? [] : minify; - // The JavaScript default belongs to minifying, not to the plugin: an - // instance that only generates reads whatever its generator takes, and a - // default naming `.js` would hide every image from it. + // The JavaScript minifier is what this plugin is for, so it is the default + // — but only for an instance that was given nothing else to do. One + // configured to generate reads whatever its generator takes, and minifying + // its images as JavaScript is not a default anyone asked for. + const minify = + typeof declaredMinify !== "undefined" + ? declaredMinify + : generate + ? [] + : /** @type {MinimizerImplementation} */ ( + /** @type {unknown} */ (terserMinify) + ); + // That default carries the `test` that belongs to it: a `.js` default over + // an instance that minifies nothing would hide every image from it. const test = typeof declaredTest !== "undefined" ? declaredTest - : minify === false + : typeof declaredMinify === "undefined" && generate ? undefined : /\.[cm]?js(\?.*)?$/i; @@ -379,8 +386,8 @@ class MinimizerPlugin { include, exclude, minimizer: - /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }} */ - (normalizeMinimizers(minimizers, resolvedMinimizerOptions)), + /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} */ + (normalizeMinimizers(minify, resolvedMinimizerOptions)), // Absent unless asked for: it runs while modules build, where the plugin // otherwise does nothing. generator: generate @@ -634,6 +641,8 @@ class MinimizerPlugin { const written = optimizeOptions.written.get(name); const says = /** @type {Record} */ (info); + const inPlace = this.rewritesInPlace(); + for (let i = 0; i < implementations.length; i++) { // A pass runs only the minimizers asking for its stage; the rest read // this same asset at theirs. @@ -641,6 +650,11 @@ class MinimizerPlugin { continue; } + // One that named a file to write does not replace this asset. + if (!inPlace(i)) { + continue; + } + const impl = implementations[i]; // Skip double minimize assets from child compilation: one already @@ -1301,7 +1315,7 @@ class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, flag: string | undefined, filename: string | undefined, filter: ((name: string, info: AssetInfo) => boolean | undefined) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ describeGenerator(name, entry, declared) { const descriptor = isDescriptor(entry) ? entry : undefined; @@ -1314,6 +1328,9 @@ class MinimizerPlugin { // where a generator's options are its own. options: (typeof own === "undefined" ? declared : own) || {}, type: descriptor ? descriptor.type : undefined, + // A generator writes a file nothing else would have written, so its work + // goes under `generated` unless it names its own. + flag: undefined, filename: descriptor ? descriptor.filename : undefined, filter: descriptor ? descriptor.filter : undefined, deleteOriginalAssets: descriptor @@ -1467,6 +1484,64 @@ class MinimizerPlugin { return this.generators().filter((one) => one.type === "asset"); } + /** + * The minimizers that write beside the asset they read rather than over it, + * shaped as the generators they are: compressing an asset is minifying it + * into a second file, so it is written under `minify` and runs here. + * @private + * @returns {ReturnType[]} them, in the order they were written + */ + sidecarMinimizers() { + const { sidecars, filters } = this.options.minimizer; + + if (!sidecars) { + return []; + } + + const minimizers = this.minimizers(); + const { options } = this.options.minimizer; + + return sidecars.flatMap((sidecar, i) => + sidecar + ? [ + { + name: undefined, + implementation: /** @type {EXPECTED_ANY} */ (minimizers[i]), + options: + /** @type {EXPECTED_ANY} */ + ( + Array.isArray(this.options.minimizer.implementation) + ? getMinimizerOptionsAt(options, i) + : options + ) || {}, + type: "asset", + // What it marks the file it writes with, which is `minimized` + // where it says nothing and `compressed` for `compress`. + flag: "minimized", + filename: sidecar.filename, + filter: filters ? filters[i] : undefined, + deleteOriginalAssets: sidecar.deleteOriginalAssets, + threshold: sidecar.threshold, + minRatio: sidecar.minRatio, + relatedName: sidecar.relatedName, + }, + ] + : [], + ); + } + + /** + * Every minimizer index that rewrites its asset in place, which is every one + * that did not name a file to write beside it. + * @private + * @returns {(i: number) => boolean} whether the minimizer at that index runs in place + */ + rewritesInPlace() { + const { sidecars } = this.options.minimizer; + + return (i) => !sidecars || !sidecars[i]; + } + /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -1664,7 +1739,10 @@ class MinimizerPlugin { // The name this generator works under, which is `generated` where it says // nothing and `compressed` for `compress`. - for (const flag of declaredFlags(generator.implementation, "generated")) { + for (const flag of declaredFlags( + generator.implementation, + generator.flag || "generated", + )) { /** @type {Record} */ (generatedInfo)[flag] = true; } @@ -1722,8 +1800,14 @@ class MinimizerPlugin { /** @type {string[]} */ const produced = []; - for (const one of this.assetGenerators()) { - for (const flag of declaredFlags(one.implementation, "generated")) { + for (const one of [ + ...this.assetGenerators(), + ...this.sidecarMinimizers(), + ]) { + for (const flag of declaredFlags( + one.implementation, + one.flag || "generated", + )) { if (!produced.includes(flag)) { produced.push(flag); } @@ -1744,7 +1828,9 @@ class MinimizerPlugin { } for (const generator of generators) { - if (generator.filter && !generator.filter(name)) { + const decides = generator.filter; + + if (decides && decides(name, asset.info) === false) { continue; } @@ -1782,10 +1868,17 @@ class MinimizerPlugin { ? implementation : [implementation]; const fallback = this.defaultStage(compiler); + const inPlace = this.rewritesInPlace(); /** @type {Map} */ const byStage = new Map(); for (let i = 0; i < each.length; i++) { + // One that named a file to write runs where a generator does, over what + // is emitted rather than over the asset it would have replaced. + if (!inPlace(i)) { + continue; + } + const asked = declaredStage(compiler, each[i]); const at = typeof asked === "number" ? asked : fallback; const already = byStage.get(at); @@ -2287,11 +2380,13 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); - // Nothing minifies, so nothing it could do varies the bundle: salting the - // hash anyway would rename every file a generator-only instance touches. - const minifies = - this.minimizerImplementations(this.options.minimizer.implementation) - .length > 0; + // Nothing rewrites an asset, so nothing this instance does varies the + // bundle: salting the hash anyway would rename every file it only writes + // beside. + const inPlace = this.rewritesInPlace(); + const minifies = this.minimizerImplementations( + this.options.minimizer.implementation, + ).some((one, i) => inPlace(i)); // The salt is the name this plugin shipped under, and every `[contenthash]` // is taken over it: renaming it would rename every file a user serves. @@ -2407,7 +2502,10 @@ class MinimizerPlugin { /** @type {Map>} */ const generatorsByStage = new Map(); - for (const generator of this.assetGenerators()) { + for (const generator of [ + ...this.assetGenerators(), + ...this.sidecarMinimizers(), + ]) { const asked = declaredStage(compiler, generator.implementation); const at = typeof asked === "number" ? asked : fallback; const already = generatorsByStage.get(at); diff --git a/src/options.json b/src/options.json index 4e14291c..91aafa9a 100644 --- a/src/options.json +++ b/src/options.json @@ -189,10 +189,6 @@ "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included.", "link": "https://github.com/webpack/minimizer-webpack-plugin#number", "anyOf": [ - { - "description": "Nothing minifies. For a plugin instance that only runs a `generate`.", - "enum": [false] - }, { "instanceof": "Function" }, @@ -220,6 +216,37 @@ "filter": { "description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.", "instanceof": "Function" + }, + "filename": { + "description": "Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does.", + "type": "string", + "minLength": 1 + }, + "threshold": { + "description": "Write beside only assets larger than this, in bytes. Needs `filename`.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the file written beside only when it is this much smaller than the asset it was read from, as `written size / original size`. Needs `filename`.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the file written beside is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. Needs `filename`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] + }, + "deleteOriginalAssets": { + "description": "Removes the asset read from. Needs `filename`.", + "type": "boolean" } }, "required": ["implementation"] @@ -243,6 +270,37 @@ "filter": { "description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.", "instanceof": "Function" + }, + "filename": { + "description": "Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does.", + "type": "string", + "minLength": 1 + }, + "threshold": { + "description": "Write beside only assets larger than this, in bytes. Needs `filename`.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the file written beside only when it is this much smaller than the asset it was read from, as `written size / original size`. Needs `filename`.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the file written beside is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. Needs `filename`.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] + }, + "deleteOriginalAssets": { + "description": "Removes the asset read from. Needs `filename`.", + "type": "boolean" } }, "required": ["implementation"] diff --git a/src/utils.js b/src/utils.js index d334627e..2bd968e5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2442,15 +2442,49 @@ function isDescriptor(entry) { ); } +/** + * What a `minify` descriptor says about writing beside the asset it read, + * which is nothing at all unless it named a file to write. + * @param {EXPECTED_ANY} descriptor one `minify` descriptor + * @returns {MinimizerSidecar | undefined} what it stated, or undefined to rewrite in place + */ +function readSidecar(descriptor) { + if (typeof descriptor.filename !== "string") { + return undefined; + } + + return { + filename: descriptor.filename, + threshold: descriptor.threshold, + minRatio: descriptor.minRatio, + relatedName: descriptor.relatedName, + deleteOriginalAssets: descriptor.deleteOriginalAssets, + }; +} + +/** + * What one `minify` descriptor says about where its result goes. A descriptor + * naming a `filename` writes its result beside the asset it read rather than + * over it, which is what compressing an asset is. + * @typedef {object} MinimizerSidecar + * @property {string} filename name for the written file, as a webpack filename template + * @property {number=} threshold only assets larger than this, in bytes + * @property {number=} minRatio keep it only when this much smaller than what it read + * @property {string | false=} relatedName the key it is recorded under on the asset it read + * @property {boolean=} deleteOriginalAssets remove the asset it read + */ + /** * Flattens the objects `minify` may hold into the implementation-and-options * pair the rest of the plugin reads, so a descriptor's own `options` and the * deprecated `minimizerOptions` end up in one place, aligned by position. - * `filters` is parallel to `implementation`, and holds only what a descriptor - * stated: an entry left undefined falls back to the function's own `filter`. + * `filters` and `sidecars` are parallel to `implementation`, and hold only what + * a descriptor stated: a `filters` entry left undefined falls back to the + * function's own `filter`, and a `sidecars` one left undefined is a minimizer + * that rewrites its asset in place. * @param {EXPECTED_ANY} minify what `minify` was set to * @param {EXPECTED_ANY} declared what `minimizerOptions` says - * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} the pair, and the filters and sidecars descriptors stated */ function normalizeMinimizers(minify, declared) { // TODO drop the `declared` fallback in the next major release, with the @@ -2463,6 +2497,9 @@ function normalizeMinimizers(minify, declared) { const filters = minify.map((one) => isDescriptor(one) ? one.filter : undefined, ); + const sidecars = minify.map((one) => + isDescriptor(one) ? readSidecar(one) : undefined, + ); return { implementation: minify.map((one) => @@ -2474,10 +2511,13 @@ function normalizeMinimizers(minify, declared) { : getMinimizerOptionsAt(declared, index), ), ...(filters.some((one) => typeof one === "function") ? { filters } : {}), + ...(sidecars.some(Boolean) ? { sidecars } : {}), }; } if (isDescriptor(minify)) { + const sidecar = readSidecar(minify); + return { implementation: minify.implementation, options: @@ -2485,6 +2525,7 @@ function normalizeMinimizers(minify, declared) { ...(typeof minify.filter === "function" ? { filters: [minify.filter] } : {}), + ...(sidecar ? { sidecars: [sidecar] } : {}), }; } diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index db5984b4..a9f5d8bf 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,17 +132,15 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - false | function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } + function | [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? } -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: - * options.minify should be false. - -> Nothing minifies. For a plugin instance that only runs a \`generate\`. * options.minify should be an instance of function. * options.minify should be an array: - [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) + [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) * options.minify should be an object: - object { implementation, options?, filter? }" + object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }" `; exports[`validation validate 11`] = ` @@ -243,3 +241,23 @@ exports[`validation validate 19`] = ` * options.minimizerOptions should be an array: [object { … }, ...] (should not have fewer than 1 item)" `; + +exports[`validation validate 20`] = ` +"Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. + - options.minify should be one of these: + function | [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. + -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number + Details: + * options.minify should be an instance of function. + * options.minify should be an array: + [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) + * options.minify should be an object: + object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }" +`; + +exports[`validation validate 21`] = ` +"Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. + - options.minify.filename should be a non-empty string. + -> Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does." +`; diff --git a/test/generate-option.test.js b/test/generate-option.test.js index ed6ce8c7..79d1388d 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1839,7 +1839,6 @@ describe("what a generated file promises about its name", () => { new MinimizerPlugin({ test: /\.png$/i, - minify: false, generate: { implementation: copy, type: "asset", filename }, }).apply(compiler); @@ -1898,7 +1897,6 @@ describe("generate with nothing to minify", () => { }); new MinimizerPlugin({ - minify: false, generate: { implementation: copy, type: "asset", @@ -1924,7 +1922,6 @@ describe("generate with nothing to minify", () => { new MinimizerPlugin({ test: /\.png$/i, - minify: false, generate: { implementation: copy, type: "asset", @@ -1955,7 +1952,6 @@ describe("generate with nothing to minify", () => { if (withPlugin) { new MinimizerPlugin({ - minify: false, generate: { implementation: (input) => ({ code: Buffer.from(Object.values(input)[0]), @@ -1981,13 +1977,21 @@ describe("generate with nothing to minify", () => { expect(with_.filter((name) => !name.includes(".copy."))).toEqual(without); }); - it("should minify nothing when `minify` is false", async () => { + it("should minify nothing when only a generator is configured", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), module: { rules: IMAGE_RULES }, }); - new MinimizerPlugin({ minify: false }).apply(compiler); + new MinimizerPlugin({ + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); const stats = await compile(compiler); const bundle = readAsset("main.js", compiler, stats); @@ -2049,7 +2053,6 @@ describe("generate from an asset emitted late", () => { new EmitLate().apply(compiler); new MinimizerPlugin({ test: /\.txt$/i, - minify: false, generate: { implementation: copy, type: "asset", @@ -2113,7 +2116,6 @@ describe("generate assets, what is worth writing", () => { new MinimizerPlugin({ test: /\.png$/i, - minify: false, generate: { implementation, type: "asset", @@ -2212,7 +2214,6 @@ describe("generate assets, what is worth writing", () => { new AlreadyCopied().apply(compiler); new MinimizerPlugin({ test: /\.png$/i, - minify: false, generate: { implementation: scale, type: "asset", diff --git a/test/stage-option.test.js b/test/stage-option.test.js index f0468690..a0a4fae6 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -911,3 +911,127 @@ describe("what a function says it wrote", () => { expect(printed).not.toContain("[minimized]"); }); }); + +describe("a minimizer that writes beside the asset it read", () => { + it("should write the compressed file rather than replace the asset", async () => { + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: { + implementation: MinimizerPlugin.compress, + options: { algorithm: "gzip" }, + filename: "[path][base].gz", + relatedName: "gzipped", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "main.js", + "main.js.gz", + ]); + expect(zlib.gunzipSync(readBytes(compiler, stats, "main.js.gz"))).toEqual( + readBytes(compiler, stats, "main.js"), + ); + + const original = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ); + const written = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js.gz") + ); + + expect(written.info.compressed).toBe(true); + expect(written.info.minimized).toBeUndefined(); + expect(original.info.minimized).toBeUndefined(); + expect( + /** @type {{ [key: string]: string }} */ (original.info.related).gzipped, + ).toBe("main.js.gz"); + }); + + it("should compress what the minimizer before it left, not what webpack rendered", async () => { + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: [ + MinimizerPlugin.terserMinify, + { + implementation: MinimizerPlugin.compress, + filename: "[path][base].gz", + }, + ], + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "main.js", + "main.js.gz", + ]); + expect(zlib.gunzipSync(readBytes(compiler, stats, "main.js.gz"))).toEqual( + readBytes(compiler, stats, "main.js"), + ); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ).info.minimized, + ).toBe(true); + }); + + it("should leave the chunk hash alone, having replaced nothing", async () => { + /** + * @param {MinimizerPlugin=} plugin the plugin to apply, or none + * @returns {Promise} the name the bundle was emitted under + */ + const build = async (plugin) => { + const compiler = getCompiler({ + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[fullhash].js", + }, + }); + + if (plugin) { + plugin.apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets).find((name) => + name.endsWith(".js"), + ); + }; + + expect( + await build( + new MinimizerPlugin({ + minify: { + implementation: MinimizerPlugin.compress, + filename: "[path][base].gz", + }, + }), + ), + ).toBe(await build()); + }); + + it("should skip an asset already carrying the name it records under", async () => { + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: { + implementation: MinimizerPlugin.compress, + filename: "[path][base].gz", + relatedName: "gzipped", + threshold: 1024 * 1024, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(Object.keys(stats.compilation.assets)).toEqual(["main.js"]); + }); +}); diff --git a/test/validate-options.test.js b/test/validate-options.test.js index 22f264ea..68222bb1 100644 --- a/test/validate-options.test.js +++ b/test/validate-options.test.js @@ -342,6 +342,29 @@ describe("validation", () => { terserOptions: { ecma: 5 }, }); }).not.toThrow(); + + expect(() => { + createCompiler({ + minify: { + implementation: () => ({ code: "" }), + filename: "[path][base].gz", + threshold: 0, + minRatio: 0.8, + relatedName: "gzipped", + deleteOriginalAssets: false, + }, + }); + }).not.toThrow(); + + expect(() => { + createCompiler({ minify: false }); + }).toThrowErrorMatchingSnapshot(); + + expect(() => { + createCompiler({ + minify: { implementation: () => ({ code: "" }), filename: "" }, + }); + }).toThrowErrorMatchingSnapshot(); }); it("should validate a minimizer added through `optimization.minimizer`", () => { diff --git a/types/index.d.ts b/types/index.d.ts index dc33673f..7c9f81d3 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -96,7 +96,7 @@ declare class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, flag: string | undefined, filename: string | undefined, filter: ((name: string, info: AssetInfo) => boolean | undefined) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ private describeGenerator; /** @@ -146,6 +146,21 @@ declare class MinimizerPlugin { * @returns {ReturnType[]} them, in the order they were written */ private assetGenerators; + /** + * The minimizers that write beside the asset they read rather than over it, + * shaped as the generators they are: compressing an asset is minifying it + * into a second file, so it is written under `minify` and runs here. + * @private + * @returns {ReturnType[]} them, in the order they were written + */ + private sidecarMinimizers; + /** + * Every minimizer index that rewrites its asset in place, which is every one + * that did not name a file to write beside it. + * @private + * @returns {(i: number) => boolean} whether the minimizer at that index runs in place + */ + private rewritesInPlace; /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -287,6 +302,7 @@ declare namespace MinimizerPlugin { JestWorker, RawSourceMap, TraceMap, + MinimizerSidecar, Rule, Rules, EXPECTED_ANY, @@ -354,6 +370,7 @@ type RawSourceMap = import("@jridgewell/trace-mapping").EncodedSourceMap & { file: string; }; type TraceMap = import("@jridgewell/trace-mapping").TraceMap; +type MinimizerSidecar = import("./utils").MinimizerSidecar; type Rule = RegExp | string; type Rules = Rule[] | Rule; type EXPECTED_ANY = any; @@ -687,6 +704,7 @@ type InternalPluginOptions = BasePluginOptions & { filters?: ( ((name: string, info: AssetInfo) => boolean | undefined) | undefined )[]; + sidecars?: (MinimizerSidecar | undefined)[]; }; generator?: { implementation: MinimizerImplementation; diff --git a/types/utils.d.ts b/types/utils.d.ts index 305b35d3..79dab637 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -5,6 +5,33 @@ export type QueryParameter = { name: string; read: (value: string) => EXPECTED_ANY; }; +/** + * What one `minify` descriptor says about where its result goes. A descriptor + * naming a `filename` writes its result beside the asset it read rather than + * over it, which is what compressing an asset is. + */ +export type MinimizerSidecar = { + /** + * name for the written file, as a webpack filename template + */ + filename: string; + /** + * only assets larger than this, in bytes + */ + threshold?: number | undefined; + /** + * keep it only when this much smaller than what it read + */ + minRatio?: number | undefined; + /** + * the key it is recorded under on the asset it read + */ + relatedName?: (string | false) | undefined; + /** + * remove the asset it read + */ + deleteOriginalAssets?: boolean | undefined; +}; export type FunctionReturning = () => T; export type ExtractCommentsOptions = import("./index.js").ExtractCommentsOptions; @@ -578,15 +605,28 @@ export namespace napiRsImageMinify { */ function filter(name: string): boolean; } +/** + * What one `minify` descriptor says about where its result goes. A descriptor + * naming a `filename` writes its result beside the asset it read rather than + * over it, which is what compressing an asset is. + * @typedef {object} MinimizerSidecar + * @property {string} filename name for the written file, as a webpack filename template + * @property {number=} threshold only assets larger than this, in bytes + * @property {number=} minRatio keep it only when this much smaller than what it read + * @property {string | false=} relatedName the key it is recorded under on the asset it read + * @property {boolean=} deleteOriginalAssets remove the asset it read + */ /** * Flattens the objects `minify` may hold into the implementation-and-options * pair the rest of the plugin reads, so a descriptor's own `options` and the * deprecated `minimizerOptions` end up in one place, aligned by position. - * `filters` is parallel to `implementation`, and holds only what a descriptor - * stated: an entry left undefined falls back to the function's own `filter`. + * `filters` and `sidecars` are parallel to `implementation`, and hold only what + * a descriptor stated: a `filters` entry left undefined falls back to the + * function's own `filter`, and a `sidecars` one left undefined is a minimizer + * that rewrites its asset in place. * @param {EXPECTED_ANY} minify what `minify` was set to * @param {EXPECTED_ANY} declared what `minimizerOptions` says - * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} the pair, and the filters and sidecars descriptors stated */ export function normalizeMinimizers( minify: EXPECTED_ANY, @@ -597,6 +637,7 @@ export function normalizeMinimizers( filters?: ( ((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined )[]; + sidecars?: (MinimizerSidecar | undefined)[]; }; /** * The version a package reports. Read by walking up from its resolved entry From a395fc232cbd8aea5a800c47ef2228860cfb78a9 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 15 Sep 2026 12:27:22 +0000 Subject: [PATCH 06/17] fix: a file written beside an asset inherits nothing from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the original's info says of its hashes, its module and where its source came from is true of that file and not of the one written beside it, so the new file starts from nothing rather than from a copy. `immutable` is the exception, and only where the name it was given still derives from the original's: that is what carried the hash the promise rests on. `flatMap` went with it — it is newer than the Node this plugin still runs on, and reaching it failed every build that wrote beside an asset. --- src/index.js | 75 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/src/index.js b/src/index.js index bc506cb9..c8c9b4e1 100644 --- a/src/index.js +++ b/src/index.js @@ -1501,33 +1501,34 @@ class MinimizerPlugin { const minimizers = this.minimizers(); const { options } = this.options.minimizer; - return sidecars.flatMap((sidecar, i) => - sidecar - ? [ - { - name: undefined, - implementation: /** @type {EXPECTED_ANY} */ (minimizers[i]), - options: - /** @type {EXPECTED_ANY} */ - ( - Array.isArray(this.options.minimizer.implementation) - ? getMinimizerOptionsAt(options, i) - : options - ) || {}, - type: "asset", - // What it marks the file it writes with, which is `minimized` - // where it says nothing and `compressed` for `compress`. - flag: "minimized", - filename: sidecar.filename, - filter: filters ? filters[i] : undefined, - deleteOriginalAssets: sidecar.deleteOriginalAssets, - threshold: sidecar.threshold, - minRatio: sidecar.minRatio, - relatedName: sidecar.relatedName, - }, - ] - : [], - ); + // `flatMap` is newer than the Node this plugin still runs on. + return sidecars.reduce((found, sidecar, i) => { + if (sidecar) { + found.push({ + name: undefined, + implementation: /** @type {EXPECTED_ANY} */ (minimizers[i]), + options: + /** @type {EXPECTED_ANY} */ + ( + Array.isArray(this.options.minimizer.implementation) + ? getMinimizerOptionsAt(options, i) + : options + ) || {}, + type: "asset", + // What it marks the file it writes with, which is `minimized` + // where it says nothing and `compressed` for `compress`. + flag: "minimized", + filename: sidecar.filename, + filter: filters ? filters[i] : undefined, + deleteOriginalAssets: sidecar.deleteOriginalAssets, + threshold: sidecar.threshold, + minRatio: sidecar.minRatio, + relatedName: sidecar.relatedName, + }); + } + + return found; + }, /** @type {ReturnType[]} */ ([])); } /** @@ -1735,7 +1736,11 @@ class MinimizerPlugin { return; } - const generatedInfo = { ...info }; + // A new file rather than a rewritten one, so it inherits nothing: what the + // original's info says of its hashes, its module and where its source came + // from is true of that file and not of this one. + /** @type {AssetInfo} */ + const generatedInfo = {}; // The name this generator works under, which is `generated` where it says // nothing and `compressed` for `compress`. @@ -1746,18 +1751,14 @@ class MinimizerPlugin { /** @type {Record} */ (generatedInfo)[flag] = true; } - delete generatedInfo.related; - - // Only where the name it was given still derives from the original's, which - // is what carried the hash the original's immutability rests on. + // The exception, and only where the name it was given still derives from + // the original's: that is what carried the hash the promise rests on. if ( info.immutable && - !( - typeof generator.filename === "string" && - /(\[name]|\[base]|\[file])/.test(generator.filename) - ) + typeof generator.filename === "string" && + /(\[name]|\[base]|\[file])/.test(generator.filename) ) { - delete generatedInfo.immutable; + generatedInfo.immutable = true; } if (compilation.getAsset(generatedName)) { From 2e05097c110633401498e5d75c5c53fc7033cfc5 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 15 Sep 2026 12:45:03 +0000 Subject: [PATCH 07/17] revert: compressing an asset is generating one, not minifying it A file written beside an asset is a new file, not a rewritten one, and the engine already had a place for that. Putting it under `minify` meant excluding those entries from the in-place pass, from the stage grouping and from the chunk-hash salt, giving five options that mean nothing to a real minimizer, and routing them through the generator's own code anyway. What that work was for is kept. `minify` still does not take `false`: the JavaScript minifier is the default only for an instance given nothing else to do, so one configured to generate minifies nothing without having to say so. And a generated file still inherits nothing from the asset it was read from beyond a conditional `immutable`. --- .changeset/generator-compression-options.md | 2 +- src/index.js | 123 +++-------------- src/options.json | 62 --------- src/utils.js | 47 +------ .../validate-options.test.js.snap | 18 +-- test/stage-option.test.js | 124 ------------------ test/validate-options.test.js | 19 --- types/index.d.ts | 20 +-- types/utils.d.ts | 47 +------ 9 files changed, 29 insertions(+), 433 deletions(-) diff --git a/.changeset/generator-compression-options.md b/.changeset/generator-compression-options.md index ce27c849..b86d3afc 100644 --- a/.changeset/generator-compression-options.md +++ b/.changeset/generator-compression-options.md @@ -2,4 +2,4 @@ "minimizer-webpack-plugin": minor --- -Let a `minify` minimizer write beside the asset it read, with `filename`, `threshold`, `minRatio`, `relatedName` and `deleteOriginalAssets`. +Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and default the minimizer only where nothing else was configured. diff --git a/src/index.js b/src/index.js index c8c9b4e1..631c13d3 100644 --- a/src/index.js +++ b/src/index.js @@ -47,7 +47,6 @@ const { /** @typedef {import("jest-worker").Worker} JestWorker */ /** @typedef {import("@jridgewell/trace-mapping").EncodedSourceMap & { sources: string[], sourcesContent?: string[], file: string }} RawSourceMap */ /** @typedef {import("@jridgewell/trace-mapping").TraceMap} TraceMap */ -/** @typedef {import("./utils").MinimizerSidecar} MinimizerSidecar */ /** @typedef {RegExp | string} Rule */ /** @typedef {Rule[] | Rule} Rules */ @@ -224,7 +223,7 @@ const { /** * @template T - * @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }, generator?: { implementation: MinimizerImplementation, options: MinimizerOptions } }} InternalPluginOptions + * @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }, generator?: { implementation: MinimizerImplementation, options: MinimizerOptions } }} InternalPluginOptions */ /** @@ -348,7 +347,7 @@ class MinimizerPlugin { // — but only for an instance that was given nothing else to do. One // configured to generate reads whatever its generator takes, and minifying // its images as JavaScript is not a default anyone asked for. - const minify = + const minimizers = typeof declaredMinify !== "undefined" ? declaredMinify : generate @@ -386,8 +385,8 @@ class MinimizerPlugin { include, exclude, minimizer: - /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} */ - (normalizeMinimizers(minify, resolvedMinimizerOptions)), + /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }} */ + (normalizeMinimizers(minimizers, resolvedMinimizerOptions)), // Absent unless asked for: it runs while modules build, where the plugin // otherwise does nothing. generator: generate @@ -641,8 +640,6 @@ class MinimizerPlugin { const written = optimizeOptions.written.get(name); const says = /** @type {Record} */ (info); - const inPlace = this.rewritesInPlace(); - for (let i = 0; i < implementations.length; i++) { // A pass runs only the minimizers asking for its stage; the rest read // this same asset at theirs. @@ -650,11 +647,6 @@ class MinimizerPlugin { continue; } - // One that named a file to write does not replace this asset. - if (!inPlace(i)) { - continue; - } - const impl = implementations[i]; // Skip double minimize assets from child compilation: one already @@ -1315,7 +1307,7 @@ class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, flag: string | undefined, filename: string | undefined, filter: ((name: string, info: AssetInfo) => boolean | undefined) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ describeGenerator(name, entry, declared) { const descriptor = isDescriptor(entry) ? entry : undefined; @@ -1328,9 +1320,6 @@ class MinimizerPlugin { // where a generator's options are its own. options: (typeof own === "undefined" ? declared : own) || {}, type: descriptor ? descriptor.type : undefined, - // A generator writes a file nothing else would have written, so its work - // goes under `generated` unless it names its own. - flag: undefined, filename: descriptor ? descriptor.filename : undefined, filter: descriptor ? descriptor.filter : undefined, deleteOriginalAssets: descriptor @@ -1484,65 +1473,6 @@ class MinimizerPlugin { return this.generators().filter((one) => one.type === "asset"); } - /** - * The minimizers that write beside the asset they read rather than over it, - * shaped as the generators they are: compressing an asset is minifying it - * into a second file, so it is written under `minify` and runs here. - * @private - * @returns {ReturnType[]} them, in the order they were written - */ - sidecarMinimizers() { - const { sidecars, filters } = this.options.minimizer; - - if (!sidecars) { - return []; - } - - const minimizers = this.minimizers(); - const { options } = this.options.minimizer; - - // `flatMap` is newer than the Node this plugin still runs on. - return sidecars.reduce((found, sidecar, i) => { - if (sidecar) { - found.push({ - name: undefined, - implementation: /** @type {EXPECTED_ANY} */ (minimizers[i]), - options: - /** @type {EXPECTED_ANY} */ - ( - Array.isArray(this.options.minimizer.implementation) - ? getMinimizerOptionsAt(options, i) - : options - ) || {}, - type: "asset", - // What it marks the file it writes with, which is `minimized` - // where it says nothing and `compressed` for `compress`. - flag: "minimized", - filename: sidecar.filename, - filter: filters ? filters[i] : undefined, - deleteOriginalAssets: sidecar.deleteOriginalAssets, - threshold: sidecar.threshold, - minRatio: sidecar.minRatio, - relatedName: sidecar.relatedName, - }); - } - - return found; - }, /** @type {ReturnType[]} */ ([])); - } - - /** - * Every minimizer index that rewrites its asset in place, which is every one - * that did not name a file to write beside it. - * @private - * @returns {(i: number) => boolean} whether the minimizer at that index runs in place - */ - rewritesInPlace() { - const { sidecars } = this.options.minimizer; - - return (i) => !sidecars || !sidecars[i]; - } - /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -1744,10 +1674,7 @@ class MinimizerPlugin { // The name this generator works under, which is `generated` where it says // nothing and `compressed` for `compress`. - for (const flag of declaredFlags( - generator.implementation, - generator.flag || "generated", - )) { + for (const flag of declaredFlags(generator.implementation, "generated")) { /** @type {Record} */ (generatedInfo)[flag] = true; } @@ -1801,14 +1728,8 @@ class MinimizerPlugin { /** @type {string[]} */ const produced = []; - for (const one of [ - ...this.assetGenerators(), - ...this.sidecarMinimizers(), - ]) { - for (const flag of declaredFlags( - one.implementation, - one.flag || "generated", - )) { + for (const one of this.assetGenerators()) { + for (const flag of declaredFlags(one.implementation, "generated")) { if (!produced.includes(flag)) { produced.push(flag); } @@ -1829,9 +1750,7 @@ class MinimizerPlugin { } for (const generator of generators) { - const decides = generator.filter; - - if (decides && decides(name, asset.info) === false) { + if (generator.filter && !generator.filter(name)) { continue; } @@ -1869,17 +1788,10 @@ class MinimizerPlugin { ? implementation : [implementation]; const fallback = this.defaultStage(compiler); - const inPlace = this.rewritesInPlace(); /** @type {Map} */ const byStage = new Map(); for (let i = 0; i < each.length; i++) { - // One that named a file to write runs where a generator does, over what - // is emitted rather than over the asset it would have replaced. - if (!inPlace(i)) { - continue; - } - const asked = declaredStage(compiler, each[i]); const at = typeof asked === "number" ? asked : fallback; const already = byStage.get(at); @@ -2381,13 +2293,11 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); - // Nothing rewrites an asset, so nothing this instance does varies the - // bundle: salting the hash anyway would rename every file it only writes - // beside. - const inPlace = this.rewritesInPlace(); - const minifies = this.minimizerImplementations( - this.options.minimizer.implementation, - ).some((one, i) => inPlace(i)); + // Nothing minifies, so nothing it could do varies the bundle: salting the + // hash anyway would rename every file a generator-only instance touches. + const minifies = + this.minimizerImplementations(this.options.minimizer.implementation) + .length > 0; // The salt is the name this plugin shipped under, and every `[contenthash]` // is taken over it: renaming it would rename every file a user serves. @@ -2503,10 +2413,7 @@ class MinimizerPlugin { /** @type {Map>} */ const generatorsByStage = new Map(); - for (const generator of [ - ...this.assetGenerators(), - ...this.sidecarMinimizers(), - ]) { + for (const generator of this.assetGenerators()) { const asked = declaredStage(compiler, generator.implementation); const at = typeof asked === "number" ? asked : fallback; const already = generatorsByStage.get(at); diff --git a/src/options.json b/src/options.json index 91aafa9a..dc8104f0 100644 --- a/src/options.json +++ b/src/options.json @@ -216,37 +216,6 @@ "filter": { "description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.", "instanceof": "Function" - }, - "filename": { - "description": "Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does.", - "type": "string", - "minLength": 1 - }, - "threshold": { - "description": "Write beside only assets larger than this, in bytes. Needs `filename`.", - "type": "number", - "minimum": 0 - }, - "minRatio": { - "description": "Keep the file written beside only when it is this much smaller than the asset it was read from, as `written size / original size`. Needs `filename`.", - "type": "number", - "exclusiveMinimum": 0 - }, - "relatedName": { - "description": "The key the file written beside is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. Needs `filename`.", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "enum": [false] - } - ] - }, - "deleteOriginalAssets": { - "description": "Removes the asset read from. Needs `filename`.", - "type": "boolean" } }, "required": ["implementation"] @@ -270,37 +239,6 @@ "filter": { "description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.", "instanceof": "Function" - }, - "filename": { - "description": "Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does.", - "type": "string", - "minLength": 1 - }, - "threshold": { - "description": "Write beside only assets larger than this, in bytes. Needs `filename`.", - "type": "number", - "minimum": 0 - }, - "minRatio": { - "description": "Keep the file written beside only when it is this much smaller than the asset it was read from, as `written size / original size`. Needs `filename`.", - "type": "number", - "exclusiveMinimum": 0 - }, - "relatedName": { - "description": "The key the file written beside is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. Needs `filename`.", - "anyOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "enum": [false] - } - ] - }, - "deleteOriginalAssets": { - "description": "Removes the asset read from. Needs `filename`.", - "type": "boolean" } }, "required": ["implementation"] diff --git a/src/utils.js b/src/utils.js index 2bd968e5..d334627e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2442,49 +2442,15 @@ function isDescriptor(entry) { ); } -/** - * What a `minify` descriptor says about writing beside the asset it read, - * which is nothing at all unless it named a file to write. - * @param {EXPECTED_ANY} descriptor one `minify` descriptor - * @returns {MinimizerSidecar | undefined} what it stated, or undefined to rewrite in place - */ -function readSidecar(descriptor) { - if (typeof descriptor.filename !== "string") { - return undefined; - } - - return { - filename: descriptor.filename, - threshold: descriptor.threshold, - minRatio: descriptor.minRatio, - relatedName: descriptor.relatedName, - deleteOriginalAssets: descriptor.deleteOriginalAssets, - }; -} - -/** - * What one `minify` descriptor says about where its result goes. A descriptor - * naming a `filename` writes its result beside the asset it read rather than - * over it, which is what compressing an asset is. - * @typedef {object} MinimizerSidecar - * @property {string} filename name for the written file, as a webpack filename template - * @property {number=} threshold only assets larger than this, in bytes - * @property {number=} minRatio keep it only when this much smaller than what it read - * @property {string | false=} relatedName the key it is recorded under on the asset it read - * @property {boolean=} deleteOriginalAssets remove the asset it read - */ - /** * Flattens the objects `minify` may hold into the implementation-and-options * pair the rest of the plugin reads, so a descriptor's own `options` and the * deprecated `minimizerOptions` end up in one place, aligned by position. - * `filters` and `sidecars` are parallel to `implementation`, and hold only what - * a descriptor stated: a `filters` entry left undefined falls back to the - * function's own `filter`, and a `sidecars` one left undefined is a minimizer - * that rewrites its asset in place. + * `filters` is parallel to `implementation`, and holds only what a descriptor + * stated: an entry left undefined falls back to the function's own `filter`. * @param {EXPECTED_ANY} minify what `minify` was set to * @param {EXPECTED_ANY} declared what `minimizerOptions` says - * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} the pair, and the filters and sidecars descriptors stated + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated */ function normalizeMinimizers(minify, declared) { // TODO drop the `declared` fallback in the next major release, with the @@ -2497,9 +2463,6 @@ function normalizeMinimizers(minify, declared) { const filters = minify.map((one) => isDescriptor(one) ? one.filter : undefined, ); - const sidecars = minify.map((one) => - isDescriptor(one) ? readSidecar(one) : undefined, - ); return { implementation: minify.map((one) => @@ -2511,13 +2474,10 @@ function normalizeMinimizers(minify, declared) { : getMinimizerOptionsAt(declared, index), ), ...(filters.some((one) => typeof one === "function") ? { filters } : {}), - ...(sidecars.some(Boolean) ? { sidecars } : {}), }; } if (isDescriptor(minify)) { - const sidecar = readSidecar(minify); - return { implementation: minify.implementation, options: @@ -2525,7 +2485,6 @@ function normalizeMinimizers(minify, declared) { ...(typeof minify.filter === "function" ? { filters: [minify.filter] } : {}), - ...(sidecar ? { sidecars: [sidecar] } : {}), }; } diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index a9f5d8bf..318e5f24 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,15 +132,15 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? } + function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: * options.minify should be an instance of function. * options.minify should be an array: - [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) + [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) * options.minify should be an object: - object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }" + object { implementation, options?, filter? }" `; exports[`validation validate 11`] = ` @@ -245,19 +245,13 @@ exports[`validation validate 19`] = ` exports[`validation validate 20`] = ` "Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? } + function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: * options.minify should be an instance of function. * options.minify should be an array: - [function | object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }, ...] (should not have fewer than 1 item) + [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) * options.minify should be an object: - object { implementation, options?, filter?, filename?, threshold?, minRatio?, relatedName?, deleteOriginalAssets? }" -`; - -exports[`validation validate 21`] = ` -"Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. - - options.minify.filename should be a non-empty string. - -> Name for a file written beside the asset this minimizer read, as a webpack filename template. Naming one makes it write there rather than over the asset, which is what compressing an asset does." + object { implementation, options?, filter? }" `; diff --git a/test/stage-option.test.js b/test/stage-option.test.js index a0a4fae6..f0468690 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -911,127 +911,3 @@ describe("what a function says it wrote", () => { expect(printed).not.toContain("[minimized]"); }); }); - -describe("a minimizer that writes beside the asset it read", () => { - it("should write the compressed file rather than replace the asset", async () => { - const compiler = getCompiler(); - - new MinimizerPlugin({ - minify: { - implementation: MinimizerPlugin.compress, - options: { algorithm: "gzip" }, - filename: "[path][base].gz", - relatedName: "gzipped", - }, - }).apply(compiler); - - const stats = await compile(compiler); - - expect(getErrors(stats)).toEqual([]); - expect(Object.keys(stats.compilation.assets).sort()).toEqual([ - "main.js", - "main.js.gz", - ]); - expect(zlib.gunzipSync(readBytes(compiler, stats, "main.js.gz"))).toEqual( - readBytes(compiler, stats, "main.js"), - ); - - const original = /** @type {import("webpack").Asset} */ ( - stats.compilation.getAsset("main.js") - ); - const written = /** @type {import("webpack").Asset} */ ( - stats.compilation.getAsset("main.js.gz") - ); - - expect(written.info.compressed).toBe(true); - expect(written.info.minimized).toBeUndefined(); - expect(original.info.minimized).toBeUndefined(); - expect( - /** @type {{ [key: string]: string }} */ (original.info.related).gzipped, - ).toBe("main.js.gz"); - }); - - it("should compress what the minimizer before it left, not what webpack rendered", async () => { - const compiler = getCompiler(); - - new MinimizerPlugin({ - minify: [ - MinimizerPlugin.terserMinify, - { - implementation: MinimizerPlugin.compress, - filename: "[path][base].gz", - }, - ], - }).apply(compiler); - - const stats = await compile(compiler); - - expect(getErrors(stats)).toEqual([]); - expect(Object.keys(stats.compilation.assets).sort()).toEqual([ - "main.js", - "main.js.gz", - ]); - expect(zlib.gunzipSync(readBytes(compiler, stats, "main.js.gz"))).toEqual( - readBytes(compiler, stats, "main.js"), - ); - expect( - /** @type {import("webpack").Asset} */ ( - stats.compilation.getAsset("main.js") - ).info.minimized, - ).toBe(true); - }); - - it("should leave the chunk hash alone, having replaced nothing", async () => { - /** - * @param {MinimizerPlugin=} plugin the plugin to apply, or none - * @returns {Promise} the name the bundle was emitted under - */ - const build = async (plugin) => { - const compiler = getCompiler({ - output: { - path: path.resolve(__dirname, "./dist"), - filename: "[name].[fullhash].js", - }, - }); - - if (plugin) { - plugin.apply(compiler); - } - - const stats = await compile(compiler); - - return Object.keys(stats.compilation.assets).find((name) => - name.endsWith(".js"), - ); - }; - - expect( - await build( - new MinimizerPlugin({ - minify: { - implementation: MinimizerPlugin.compress, - filename: "[path][base].gz", - }, - }), - ), - ).toBe(await build()); - }); - - it("should skip an asset already carrying the name it records under", async () => { - const compiler = getCompiler(); - - new MinimizerPlugin({ - minify: { - implementation: MinimizerPlugin.compress, - filename: "[path][base].gz", - relatedName: "gzipped", - threshold: 1024 * 1024, - }, - }).apply(compiler); - - const stats = await compile(compiler); - - expect(getErrors(stats)).toEqual([]); - expect(Object.keys(stats.compilation.assets)).toEqual(["main.js"]); - }); -}); diff --git a/test/validate-options.test.js b/test/validate-options.test.js index 68222bb1..3f6bf8f2 100644 --- a/test/validate-options.test.js +++ b/test/validate-options.test.js @@ -343,28 +343,9 @@ describe("validation", () => { }); }).not.toThrow(); - expect(() => { - createCompiler({ - minify: { - implementation: () => ({ code: "" }), - filename: "[path][base].gz", - threshold: 0, - minRatio: 0.8, - relatedName: "gzipped", - deleteOriginalAssets: false, - }, - }); - }).not.toThrow(); - expect(() => { createCompiler({ minify: false }); }).toThrowErrorMatchingSnapshot(); - - expect(() => { - createCompiler({ - minify: { implementation: () => ({ code: "" }), filename: "" }, - }); - }).toThrowErrorMatchingSnapshot(); }); it("should validate a minimizer added through `optimization.minimizer`", () => { diff --git a/types/index.d.ts b/types/index.d.ts index 7c9f81d3..dc33673f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -96,7 +96,7 @@ declare class MinimizerPlugin { * @param {string | undefined} name the preset it is written under, where it has one * @param {EXPECTED_ANY} entry what was written there * @param {EXPECTED_ANY} declared what `generatorOptions` says for it - * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, flag: string | undefined, filename: string | undefined, filter: ((name: string, info: AssetInfo) => boolean | undefined) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ private describeGenerator; /** @@ -146,21 +146,6 @@ declare class MinimizerPlugin { * @returns {ReturnType[]} them, in the order they were written */ private assetGenerators; - /** - * The minimizers that write beside the asset they read rather than over it, - * shaped as the generators they are: compressing an asset is minifying it - * into a second file, so it is written under `minify` and runs here. - * @private - * @returns {ReturnType[]} them, in the order they were written - */ - private sidecarMinimizers; - /** - * Every minimizer index that rewrites its asset in place, which is every one - * that did not name a file to write beside it. - * @private - * @returns {(i: number) => boolean} whether the minimizer at that index runs in place - */ - private rewritesInPlace; /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -302,7 +287,6 @@ declare namespace MinimizerPlugin { JestWorker, RawSourceMap, TraceMap, - MinimizerSidecar, Rule, Rules, EXPECTED_ANY, @@ -370,7 +354,6 @@ type RawSourceMap = import("@jridgewell/trace-mapping").EncodedSourceMap & { file: string; }; type TraceMap = import("@jridgewell/trace-mapping").TraceMap; -type MinimizerSidecar = import("./utils").MinimizerSidecar; type Rule = RegExp | string; type Rules = Rule[] | Rule; type EXPECTED_ANY = any; @@ -704,7 +687,6 @@ type InternalPluginOptions = BasePluginOptions & { filters?: ( ((name: string, info: AssetInfo) => boolean | undefined) | undefined )[]; - sidecars?: (MinimizerSidecar | undefined)[]; }; generator?: { implementation: MinimizerImplementation; diff --git a/types/utils.d.ts b/types/utils.d.ts index 79dab637..305b35d3 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -5,33 +5,6 @@ export type QueryParameter = { name: string; read: (value: string) => EXPECTED_ANY; }; -/** - * What one `minify` descriptor says about where its result goes. A descriptor - * naming a `filename` writes its result beside the asset it read rather than - * over it, which is what compressing an asset is. - */ -export type MinimizerSidecar = { - /** - * name for the written file, as a webpack filename template - */ - filename: string; - /** - * only assets larger than this, in bytes - */ - threshold?: number | undefined; - /** - * keep it only when this much smaller than what it read - */ - minRatio?: number | undefined; - /** - * the key it is recorded under on the asset it read - */ - relatedName?: (string | false) | undefined; - /** - * remove the asset it read - */ - deleteOriginalAssets?: boolean | undefined; -}; export type FunctionReturning = () => T; export type ExtractCommentsOptions = import("./index.js").ExtractCommentsOptions; @@ -605,28 +578,15 @@ export namespace napiRsImageMinify { */ function filter(name: string): boolean; } -/** - * What one `minify` descriptor says about where its result goes. A descriptor - * naming a `filename` writes its result beside the asset it read rather than - * over it, which is what compressing an asset is. - * @typedef {object} MinimizerSidecar - * @property {string} filename name for the written file, as a webpack filename template - * @property {number=} threshold only assets larger than this, in bytes - * @property {number=} minRatio keep it only when this much smaller than what it read - * @property {string | false=} relatedName the key it is recorded under on the asset it read - * @property {boolean=} deleteOriginalAssets remove the asset it read - */ /** * Flattens the objects `minify` may hold into the implementation-and-options * pair the rest of the plugin reads, so a descriptor's own `options` and the * deprecated `minimizerOptions` end up in one place, aligned by position. - * `filters` and `sidecars` are parallel to `implementation`, and hold only what - * a descriptor stated: a `filters` entry left undefined falls back to the - * function's own `filter`, and a `sidecars` one left undefined is a minimizer - * that rewrites its asset in place. + * `filters` is parallel to `implementation`, and holds only what a descriptor + * stated: an entry left undefined falls back to the function's own `filter`. * @param {EXPECTED_ANY} minify what `minify` was set to * @param {EXPECTED_ANY} declared what `minimizerOptions` says - * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[], sidecars?: (MinimizerSidecar | undefined)[] }} the pair, and the filters and sidecars descriptors stated + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated */ export function normalizeMinimizers( minify: EXPECTED_ANY, @@ -637,7 +597,6 @@ export function normalizeMinimizers( filters?: ( ((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined )[]; - sidecars?: (MinimizerSidecar | undefined)[]; }; /** * The version a package reports. Read by walking up from its resolved entry From 6709943b6776b8d6be704269a94627c369a391fe Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 17 Sep 2026 11:56:13 +0000 Subject: [PATCH 08/17] fix: the default minifier and its test stand whether or not a generator does Configuring a generator is not a reason to turn the JavaScript minifier off, nor to drop the `.js` default a user's `test` overrides. `terserMinify` declines anything that is not a `.js` file by name through its own `filter`, so an instance written for images is unharmed by a default that names JavaScript. The chunk-hash salt goes back to being unconditional with it: something always rewrites an asset now, so the salt is always earned. --- .changeset/generator-compression-options.md | 2 +- src/index.js | 39 +++------ test/generate-option.test.js | 92 ++++++++++----------- 3 files changed, 54 insertions(+), 79 deletions(-) diff --git a/.changeset/generator-compression-options.md b/.changeset/generator-compression-options.md index b86d3afc..f235fee8 100644 --- a/.changeset/generator-compression-options.md +++ b/.changeset/generator-compression-options.md @@ -2,4 +2,4 @@ "minimizer-webpack-plugin": minor --- -Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and default the minimizer only where nothing else was configured. +Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and stop `minify` from taking `false`. diff --git a/src/index.js b/src/index.js index 631c13d3..7c4c7b06 100644 --- a/src/index.js +++ b/src/index.js @@ -343,26 +343,16 @@ class MinimizerPlugin { generatorOptions, } = this.rawOptions; - // The JavaScript minifier is what this plugin is for, so it is the default - // — but only for an instance that was given nothing else to do. One - // configured to generate reads whatever its generator takes, and minifying - // its images as JavaScript is not a default anyone asked for. + // The JavaScript minifier and the names it reads are what this plugin is, + // so both stand whether or not a generator was configured too. const minimizers = typeof declaredMinify !== "undefined" ? declaredMinify - : generate - ? [] - : /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) - ); - // That default carries the `test` that belongs to it: a `.js` default over - // an instance that minifies nothing would hide every image from it. + : /** @type {MinimizerImplementation} */ ( + /** @type {unknown} */ (terserMinify) + ); const test = - typeof declaredTest !== "undefined" - ? declaredTest - : typeof declaredMinify === "undefined" && generate - ? undefined - : /\.[cm]?js(\?.*)?$/i; + typeof declaredTest !== "undefined" ? declaredTest : /\.[cm]?js(\?.*)?$/i; // `terserOptions` is a deprecated alias of `minimizerOptions`; prefer the // new name when both are provided. @@ -2293,20 +2283,12 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); - // Nothing minifies, so nothing it could do varies the bundle: salting the - // hash anyway would rename every file a generator-only instance touches. - const minifies = - this.minimizerImplementations(this.options.minimizer.implementation) - .length > 0; - // The salt is the name this plugin shipped under, and every `[contenthash]` // is taken over it: renaming it would rename every file a user serves. - if (minifies) { - hooks.chunkHash.tap(pluginName, (chunk, hash) => { - hash.update("TerserPlugin"); - hash.update(data); - }); - } + hooks.chunkHash.tap(pluginName, (chunk, hash) => { + hash.update("TerserPlugin"); + hash.update(data); + }); // Added in webpack 5.110: source one language embeds in another, which no // asset carries and `processAssets` therefore never sees. @@ -2315,7 +2297,6 @@ class MinimizerPlugin { (/** @type {unknown} */ (compilation.hooks)); if ( - minifies && embeddedHooks.renderEmbeddedSource && embeddedHooks.embeddedSourceHash ) { diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 79d1388d..edce4b3a 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1865,7 +1865,7 @@ describe("what a generated file promises about its name", () => { }); }); -describe("generate with nothing to minify", () => { +describe("generate beside the minifier", () => { /** * @returns {EXPECTED_ANY} a generator that hands back what it read */ @@ -1889,7 +1889,7 @@ describe("generate with nothing to minify", () => { return copy; }; - it("should reach every asset when `minify` is false and `test` is not set", async () => { + it("should read the `.js` default where no `test` was set", async () => { const copy = copier(); const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), @@ -1906,10 +1906,9 @@ describe("generate with nothing to minify", () => { const stats = await compile(compiler); - // The `.js` default belongs to minifying: with nothing minifying it would - // hide every image from the generator, which is the whole job here. - expect(copy.saw).toContain("image.png"); - expect(copy.saw).toContain("image.svg"); + // The default is the plugin's, not the minifier's, so a generator written + // for images is given a `test` that names them. + expect(copy.saw).toEqual(["main.js"]); expect(getErrors(stats)).toEqual([]); }); @@ -1935,49 +1934,43 @@ describe("generate with nothing to minify", () => { expect(getErrors(stats)).toEqual([]); }); - it("should not change what the build is named when it minifies nothing", async () => { - /** - * @param {boolean} withPlugin whether to apply the plugin - * @returns {Promise} the emitted names - */ - const namesFrom = async (withPlugin) => { - const compiler = getCompiler({ - entry: path.resolve(__dirname, "./fixtures/images.js"), - output: { - path: path.resolve(__dirname, "./dist"), - filename: "[name].js?ver=[fullhash]", - }, - module: { rules: IMAGE_RULES }, - }); - - if (withPlugin) { - new MinimizerPlugin({ - generate: { - implementation: (input) => ({ - code: Buffer.from(Object.values(input)[0]), - }), - type: "asset", - filename: "[path][name].copy[ext]", - }, - }).apply(compiler); - } - - const stats = await compile(compiler); + it("should minify as well as generate, and mark what it minified", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); - return Object.keys(stats.compilation.assets) - .filter((name) => name.includes(".js?ver=")) - .sort(); - }; + new MinimizerPlugin({ + test: /\.(png|js)$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); - const without = await namesFrom(false); - const with_ = await namesFrom(true); + const stats = await compile(compiler); - // The plugin salts the chunk hash with what its minimizers are, and with - // none there is nothing to vary: adding it must not rename a user's files. - expect(with_.filter((name) => !name.includes(".copy."))).toEqual(without); + // Both jobs run: the default minifier over the bundle, the generator over + // what `test` named — and `terserMinify` declines the image itself. + expect(readAsset("main.js", compiler, stats)).not.toContain("\n"); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ).info.minimized, + ).toBe(true); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.png") + ).info.minimized, + ).toBeUndefined(); + expect(Object.keys(stats.compilation.assets)).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); }); - it("should minify nothing when only a generator is configured", async () => { + it("should still minify when only a generator was configured", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), module: { rules: IMAGE_RULES }, @@ -1994,13 +1987,14 @@ describe("generate with nothing to minify", () => { }).apply(compiler); const stats = await compile(compiler); - const bundle = readAsset("main.js", compiler, stats); - // Left as webpack rendered it: no minimizer ran, and the asset says so. - expect(bundle).toContain("\n"); + // Configuring a generator does not turn the minifier off. + expect(readAsset("main.js", compiler, stats)).not.toContain("\n"); expect( - stats.compilation.getAsset("main.js").info.minimized, - ).toBeUndefined(); + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ).info.minimized, + ).toBe(true); expect(getErrors(stats)).toEqual([]); }); }); From c1a6f4326b7c0739b181704c337ef076e96a519d Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 17 Sep 2026 12:03:04 +0000 Subject: [PATCH 09/17] fix: do not salt the hash of a chunk no minimizer would be handed The salt makes a chunk's name vary with what its minimizer is and what it is run with, which is only true of a chunk one of them rewrites. Where `test` names something else, or every minimizer's own filter declines the name, the chunk is left alone and salting it renamed a file nothing here had touched. The name is read off the template while the hash inside it is still being computed, so every hash placeholder stands for one character: what is asked of the name is its path and extension, which no hash carries. A template a function names is unknowable, and salts as before. --- src/index.js | 68 +++++++++++++++++- test/__snapshots__/test-option.test.js.snap | 4 +- test/generate-option.test.js | 80 +++++++++++++++++++++ types/index.d.ts | 9 +++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index 7c4c7b06..4ff0ee6a 100644 --- a/src/index.js +++ b/src/index.js @@ -256,6 +256,34 @@ const declaredStage = (compiler, implementation) => { return latest; }; +/** + * The name a chunk's JavaScript asset will take, as far as it is knowable + * while the hash that name contains is still being computed. + * @param {Compilation} compilation compilation + * @param {import("webpack").Chunk} chunk chunk + * @returns {string | undefined} the name, or undefined where a function names it + */ +const chunkAssetName = (compilation, chunk) => { + const { outputOptions } = compilation; + const template = + chunk.filenameTemplate || + (chunk.canBeInitial() + ? outputOptions.filename + : outputOptions.chunkFilename); + + if (typeof template !== "string") { + return undefined; + } + + // Every hash stands for one character it has not got yet: what is being + // asked of the name is its path and extension, which no hash carries. + return template + .replace(/\[(?:full|chunk|content)hash(?::\d+)?]/gi, "0") + .replace(/\[name]/gi, String(chunk.name || chunk.id || "")) + .replace(/\[id]/gi, String(chunk.id || "")) + .replace(/\[runtime]/gi, String(chunk.runtime || "")); +}; + /** * The names an implementation's work goes under in an asset's info, which is * the union where several ran as one chain. @@ -562,6 +590,31 @@ class MinimizerPlugin { return !(exclude && (matchPart(name, exclude) || matchPart(bare, exclude))); } + /** + * Whether any configured minimizer would be handed an asset of this name, + * by the plugin's own `test`/`include`/`exclude` and then by its own filter. + * @private + * @param {Compiler} compiler compiler + * @param {string} name asset name + * @returns {boolean} true when one of them would take it + */ + minifiesName(compiler, name) { + if (!this.matchesName(compiler, name)) { + return false; + } + + const { filters } = this.options.minimizer; + + return this.minimizers().some((implementation, i) => { + const decides = + filters && typeof filters[i] === "function" + ? filters[i] + : implementation.filter; + + return typeof decides !== "function" || decides(name, {}) !== false; + }); + } + /** * @private * @param {Compiler} compiler compiler @@ -2283,9 +2336,20 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); - // The salt is the name this plugin shipped under, and every `[contenthash]` - // is taken over it: renaming it would rename every file a user serves. hooks.chunkHash.tap(pluginName, (chunk, hash) => { + const willBe = chunkAssetName(compilation, chunk); + + // A chunk no minimizer here would be handed cannot vary with them, so + // salting it would rename a file this instance never rewrites. + if ( + typeof willBe === "string" && + !this.minifiesName(compiler, willBe) + ) { + return; + } + + // The salt is the name this plugin shipped under, and every + // `[fullhash]` is taken over it: renaming it would rename every file. hash.update("TerserPlugin"); hash.update(data); }); diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index 34e35a9a..f191d6cc 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -715,7 +715,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; @@ -766,7 +766,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; diff --git a/test/generate-option.test.js b/test/generate-option.test.js index edce4b3a..a5c37eb8 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1970,6 +1970,86 @@ describe("generate beside the minifier", () => { expect(getErrors(stats)).toEqual([]); }); + it("should not rename a bundle no minimizer of its own would touch", async () => { + /** + * @param {boolean} withPlugin whether to apply the plugin + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (withPlugin) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + if (withPlugin) { + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // `test` names images, so no minimizer here is ever handed the bundle: + // salting its hash would rename a file this instance never rewrites. + expect(await namesFrom(true)).toEqual(await namesFrom(false)); + }); + + it("should still rename when a minimizer would be handed the bundle", async () => { + /** + * @param {EXPECTED_ANY} minimizerOptions what to run terser with + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (minimizerOptions) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + minimizerOptions, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // The guard above must not cost the salt its job: what terser is run with + // still varies the name of what it rewrote. + expect(await namesFrom({ mangle: true })).not.toEqual( + await namesFrom({ mangle: false }), + ); + }); + it("should still minify when only a generator was configured", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), diff --git a/types/index.d.ts b/types/index.d.ts index dc33673f..cbbcca09 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -62,6 +62,15 @@ declare class MinimizerPlugin { * @returns {boolean} true when it is to be minified */ private matchesName; + /** + * Whether any configured minimizer would be handed an asset of this name, + * by the plugin's own `test`/`include`/`exclude` and then by its own filter. + * @private + * @param {Compiler} compiler compiler + * @param {string} name asset name + * @returns {boolean} true when one of them would take it + */ + private minifiesName; /** * @private * @param {Compiler} compiler compiler From 286333996218701708d422a4217d1cfaed89b977 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 17 Sep 2026 12:07:12 +0000 Subject: [PATCH 10/17] test: cover reading a chunk's name off a template, and drop what cannot be reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file a function names cannot be read ahead of being called, so the salt stands rather than being skipped on a guess — the one case the guard has to get right in the safe direction, and now a test. The empty-string fallbacks behind each placeholder went with it: a chunk carrying neither a name nor an id is not a chunk, and what those arms guarded against was a name this only reads a path and an extension from. --- src/index.js | 10 ++++----- test/generate-option.test.js | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/index.js b/src/index.js index 4ff0ee6a..b0ae30c2 100644 --- a/src/index.js +++ b/src/index.js @@ -275,13 +275,13 @@ const chunkAssetName = (compilation, chunk) => { return undefined; } - // Every hash stands for one character it has not got yet: what is being - // asked of the name is its path and extension, which no hash carries. + // Every placeholder stands for text it has not got yet: what is being asked + // of the name is its path and extension, which none of them carries. return template .replace(/\[(?:full|chunk|content)hash(?::\d+)?]/gi, "0") - .replace(/\[name]/gi, String(chunk.name || chunk.id || "")) - .replace(/\[id]/gi, String(chunk.id || "")) - .replace(/\[runtime]/gi, String(chunk.runtime || "")); + .replace(/\[name]/gi, String(chunk.name || chunk.id)) + .replace(/\[id]/gi, String(chunk.id)) + .replace(/\[runtime]/gi, String(chunk.runtime)); }; /** diff --git a/test/generate-option.test.js b/test/generate-option.test.js index a5c37eb8..89806c32 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -2050,6 +2050,46 @@ describe("generate beside the minifier", () => { ); }); + it("should salt where a function names the file, which cannot be read ahead", async () => { + /** + * @param {boolean} withPlugin whether to apply the plugin + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (withPlugin) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: () => "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + if (withPlugin) { + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // Nothing can be read off a function before it is called, so the salt + // stands rather than being skipped on a guess. + expect(await namesFrom(true)).not.toEqual(await namesFrom(false)); + }); + it("should still minify when only a generator was configured", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), From 47578e527eb7de3800c3bce2af132d7ff5f4cf60 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 17 Sep 2026 12:11:58 +0000 Subject: [PATCH 11/17] refactor: drop the minimizer list helper nothing calls any more Its only caller was the guard that asked whether anything minified at all, and that question has no answer to give now that something always does. Its own description still said an empty list was `minify: false`, which is no longer a thing to say. --- src/index.js | 11 ----------- types/index.d.ts | 8 -------- 2 files changed, 19 deletions(-) diff --git a/src/index.js b/src/index.js index b0ae30c2..ab605d1d 100644 --- a/src/index.js +++ b/src/index.js @@ -1468,17 +1468,6 @@ class MinimizerPlugin { return this.generators().some((one) => one.type !== "asset"); } - /** - * The minimizers as a list, whichever shape they were written in. Empty is - * `minify: false`, which is what every pass over them then does nothing for. - * @private - * @param {EXPECTED_ANY} implementation one implementation, or an array - * @returns {EXPECTED_ANY[]} them - */ - minimizerImplementations(implementation) { - return Array.isArray(implementation) ? implementation : [implementation]; - } - /** * Every name the functions this plugin runs mark an asset with, which is * what stats have to know how to print. diff --git a/types/index.d.ts b/types/index.d.ts index cbbcca09..b9f1307a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -133,14 +133,6 @@ declare class MinimizerPlugin { * @returns {boolean} true when one does */ private hasModuleGenerator; - /** - * The minimizers as a list, whichever shape they were written in. Empty is - * `minify: false`, which is what every pass over them then does nothing for. - * @private - * @param {EXPECTED_ANY} implementation one implementation, or an array - * @returns {EXPECTED_ANY[]} them - */ - private minimizerImplementations; /** * Every name the functions this plugin runs mark an asset with, which is * what stats have to know how to print. From 4bf096c24c8c47776f79f1fac8206ed223330a29 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Thu, 17 Sep 2026 12:32:47 +0000 Subject: [PATCH 12/17] fix: four things an automated review was right about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an asset takes everything its `related` names with it, so recording the generated file there and then deleting the original deleted the file just written — `relatedName` and `deleteOriginalAssets` together emitted nothing at all. The one is now recorded only where the other leaves the original standing, which is what `compression-webpack-plugin` has always done. A generated name that already existed was written over and then returned from, before either of those was reached, so a rebuild owed what a first build paid. The chunk-hash guard asked a `filter` for its answer while handing it an empty object for the asset info it reads, which could decline a chunk it then took: only `test`/`include`/`exclude` decide there, being the part that reads a name. And the descriptor an `asset` generator is written as had no type, so none of `type`, `filename`, `threshold`, `minRatio` or `relatedName` could be reached from TypeScript at all; `minify` no longer offers the `false` the schema rejects. --- src/index.js | 78 +++++++-------- test/generate-option.test.js | 177 +++++++++++++++++++++++++++++++++++ types/index.d.ts | 74 ++++++++++++--- 3 files changed, 277 insertions(+), 52 deletions(-) diff --git a/src/index.js b/src/index.js index ab605d1d..8e730ac4 100644 --- a/src/index.js +++ b/src/index.js @@ -205,6 +205,26 @@ const { * @typedef {undefined | boolean | number} Parallel */ +/** + * One generator, written as an object stating how to run it. + * @typedef {object} GeneratorDescriptor + * @property {MinimizerImplementation} implementation the generator itself + * @property {MinimizerOptions=} options options for this generator, preferred over the deprecated `generatorOptions` + * @property {("import" | "asset")=} type `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted + * @property {string=} filename name for the generated asset, as a webpack filename template. `asset` generators only + * @property {((name: string) => boolean)=} filter decides per asset whether to generate from it, on top of `test`/`include`/`exclude` + * @property {boolean=} deleteOriginalAssets removes the asset generated from. `asset` generators only + * @property {number=} threshold generate only from assets larger than this, in bytes. `asset` generators only + * @property {number=} minRatio keep the generated asset only when it is this much smaller than the one it was read from. `asset` generators only + * @property {(string | false)=} relatedName the key the generated asset is recorded under in the original's `related` info. `asset` generators only + */ + +/** + * What `generate` may be written as: one generator, a list of them, a + * descriptor, or an object naming descriptors an asset asks for with `?as=`. + * @typedef {MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor | { [preset: string]: MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor }} Generate + */ + /** * @typedef {object} BasePluginOptions * @property {Rules=} test test rule @@ -212,13 +232,13 @@ const { * @property {Rules=} exclude exclude rule * @property {ExtractCommentsOptions=} extractComments extract comments options * @property {Parallel=} parallel parallel option - * @property {MinimizerImplementation=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset + * @property {Generate=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset, or writes a new file beside one already emitted * @property {MinimizerOptions=} generatorOptions options for `generate` */ /** * @template T - * @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation | false | undefined, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined } : { minify: MinimizerImplementation | false, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined }} DefinedDefaultMinimizerAndOptions + * @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation | undefined, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined } : { minify: MinimizerImplementation, minimizerOptions?: MinimizerOptions | undefined, terserOptions?: MinimizerOptions | undefined }} DefinedDefaultMinimizerAndOptions */ /** @@ -590,31 +610,6 @@ class MinimizerPlugin { return !(exclude && (matchPart(name, exclude) || matchPart(bare, exclude))); } - /** - * Whether any configured minimizer would be handed an asset of this name, - * by the plugin's own `test`/`include`/`exclude` and then by its own filter. - * @private - * @param {Compiler} compiler compiler - * @param {string} name asset name - * @returns {boolean} true when one of them would take it - */ - minifiesName(compiler, name) { - if (!this.matchesName(compiler, name)) { - return false; - } - - const { filters } = this.options.minimizer; - - return this.minimizers().some((implementation, i) => { - const decides = - filters && typeof filters[i] === "function" - ? filters[i] - : implementation.filter; - - return typeof decides !== "function" || decides(name, {}) !== false; - }); - } - /** * @private * @param {Compiler} compiler compiler @@ -1720,14 +1715,24 @@ class MinimizerPlugin { generatedInfo.immutable = true; } + // A rebuild writes over the file it wrote last time rather than a new one, + // and what is recorded below is owed either way. if (compilation.getAsset(generatedName)) { compilation.updateAsset(generatedName, generatedSource, generatedInfo); + } else { + compilation.emitAsset(generatedName, generatedSource, generatedInfo); + } + + if (generator.deleteOriginalAssets) { + // Deleting an asset takes everything its `related` names with it, so + // recording this file there first would delete the file just written. + if (compilation.getAsset(name)) { + compilation.deleteAsset(name); + } return; } - compilation.emitAsset(generatedName, generatedSource, generatedInfo); - // Recorded on the asset it was read from, which is how a server asked for // that one finds this one. if (generator.relatedName) { @@ -1735,10 +1740,6 @@ class MinimizerPlugin { related: { [generator.relatedName]: generatedName }, }); } - - if (generator.deleteOriginalAssets && compilation.getAsset(name)) { - compilation.deleteAsset(name); - } } /** @@ -2328,12 +2329,11 @@ class MinimizerPlugin { hooks.chunkHash.tap(pluginName, (chunk, hash) => { const willBe = chunkAssetName(compilation, chunk); - // A chunk no minimizer here would be handed cannot vary with them, so - // salting it would rename a file this instance never rewrites. - if ( - typeof willBe === "string" && - !this.minifiesName(compiler, willBe) - ) { + // A chunk this instance was never pointed at cannot vary with its + // minimizers, so salting it would rename a file it never rewrites. A + // `filter` is not asked: it reads an asset's info, which no asset has + // yet, and guessing one could skip the salt for an asset it then takes. + if (typeof willBe === "string" && !this.matchesName(compiler, willBe)) { return; } diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 89806c32..3bb54aca 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -2119,6 +2119,183 @@ describe("generate beside the minifier", () => { }); }); +describe("generate over a file that is already there", () => { + it("should record `related` and delete the original even when the file exists", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, before it runs. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + expect(getErrors(stats)).toEqual([]); + // Written over rather than emitted, and the original still gone with it. + expect(names).toContain("image.copy.png"); + expect(names).not.toContain("image.png"); + expect(readAsset("image.copy.png", compiler, stats)).not.toBe("stale"); + }); + + it("should record `related` on the original where it is kept", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, before it runs. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + }, + }).apply(compiler); + + const stats = await compile(compiler); + const original = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.png") + ); + + expect(getErrors(stats)).toEqual([]); + expect( + /** @type {{ [key: string]: string }} */ (original.info.related).copied, + ).toBe("image.copy.png"); + }); +}); + +describe("deleting the asset a file was written beside", () => { + it("should keep the generated file when `relatedName` is set too", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + // Deleting an asset takes everything its `related` names with it, so the + // two together must not delete the file that was just written. + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.copy.png"); + expect(names).not.toContain("image.png"); + }); + + it("should not mind a second generator having deleted it already", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** + * @param {string} suffix what to name what it writes + * @returns {EXPECTED_ANY} one generator + */ + const copyTo = (suffix) => ({ + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: `[path][name].${suffix}[ext]`, + deleteOriginalAssets: true, + }); + + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { one: copyTo("one"), two: copyTo("two") }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + // Both wrote, and whichever deleted second found nothing left to delete. + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.one.png"); + expect(names).toContain("image.two.png"); + expect(names).not.toContain("image.png"); + }); +}); + describe("generate from an asset emitted late", () => { it("should generate from an asset added after the generators ran", async () => { const seen = []; diff --git a/types/index.d.ts b/types/index.d.ts index b9f1307a..599c9429 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -62,15 +62,6 @@ declare class MinimizerPlugin { * @returns {boolean} true when it is to be minified */ private matchesName; - /** - * Whether any configured minimizer would be handed an asset of this name, - * by the plugin's own `test`/`include`/`exclude` and then by its own filter. - * @private - * @param {Compiler} compiler compiler - * @param {string} name asset name - * @returns {boolean} true when one of them would take it - */ - private minifiesName; /** * @private * @param {Compiler} compiler compiler @@ -314,6 +305,8 @@ declare namespace MinimizerPlugin { InternalOptions, MinimizerWorker, Parallel, + GeneratorDescriptor, + Generate, BasePluginOptions, DefinedDefaultMinimizerAndOptions, InternalPluginOptions, @@ -639,6 +632,61 @@ type MinimizerWorker = JestWorker & { minify: (options: InternalOptions) => Promise; }; type Parallel = undefined | boolean | number; +/** + * One generator, written as an object stating how to run it. + */ +type GeneratorDescriptor = { + /** + * the generator itself + */ + implementation: MinimizerImplementation; + /** + * options for this generator, preferred over the deprecated `generatorOptions` + */ + options?: MinimizerOptions | undefined; + /** + * `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted + */ + type?: ("import" | "asset") | undefined; + /** + * name for the generated asset, as a webpack filename template. `asset` generators only + */ + filename?: string | undefined; + /** + * decides per asset whether to generate from it, on top of `test`/`include`/`exclude` + */ + filter?: ((name: string) => boolean) | undefined; + /** + * removes the asset generated from. `asset` generators only + */ + deleteOriginalAssets?: boolean | undefined; + /** + * generate only from assets larger than this, in bytes. `asset` generators only + */ + threshold?: number | undefined; + /** + * keep the generated asset only when it is this much smaller than the one it was read from. `asset` generators only + */ + minRatio?: number | undefined; + /** + * the key the generated asset is recorded under in the original's `related` info. `asset` generators only + */ + relatedName?: (string | false) | undefined; +}; +/** + * What `generate` may be written as: one generator, a list of them, a + * descriptor, or an object naming descriptors an asset asks for with `?as=`. + */ +type Generate = + | MinimizerImplementation + | MinimizerImplementation[] + | GeneratorDescriptor + | { + [preset: string]: + | MinimizerImplementation + | MinimizerImplementation[] + | GeneratorDescriptor; + }; type BasePluginOptions = { /** * test rule @@ -661,9 +709,9 @@ type BasePluginOptions = { */ parallel?: Parallel | undefined; /** - * rewrites a module's own bytes as it is built, so a re-encoding can rename the asset + * rewrites a module's own bytes as it is built, so a re-encoding can rename the asset, or writes a new file beside one already emitted */ - generate?: MinimizerImplementation | undefined; + generate?: Generate | undefined; /** * options for `generate` */ @@ -672,12 +720,12 @@ type BasePluginOptions = { type DefinedDefaultMinimizerAndOptions = T extends import("terser").MinifyOptions ? { - minify?: MinimizerImplementation | false | undefined; + minify?: MinimizerImplementation | undefined; minimizerOptions?: MinimizerOptions | undefined; terserOptions?: MinimizerOptions | undefined; } : { - minify: MinimizerImplementation | false; + minify: MinimizerImplementation; minimizerOptions?: MinimizerOptions | undefined; terserOptions?: MinimizerOptions | undefined; }; From 0062a9f7d1e31b8831dc2fe5d66a27c3953d7fbc Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 11:41:46 +0000 Subject: [PATCH 13/17] test: one tap per stage asked for, and one where they coincide Reads the taps the plugin left on processAssets rather than only the order its work ran in, so collapsing several stages into one hook fails here. --- test/stage-option.test.js | 151 ++++++++++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 24 deletions(-) diff --git a/test/stage-option.test.js b/test/stage-option.test.js index f0468690..393d12fb 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -56,6 +56,52 @@ const lateGenerator = (order, label) => { return run; }; +/** + * @param {string[]} order where to record + * @param {string} label what to record + * @param {number=} stage the stage it asks for, if any + * @returns {EXPECTED_ANY} the minimizer + */ +const asking = (order, label, stage) => { + /** + * @param {Record} input input + * @returns {{ code: string | Buffer }} the result + */ + const run = (input) => { + order.push(label); + + return { code: Object.values(input)[0] }; + }; + + if (typeof stage === "number") { + run.getStage = () => stage; + } + + return run; +}; + +/** + * The stages this plugin taps `processAssets` in, filled as the compilation + * starts rather than when this is called. Reads whatever was applied before + * it, so it is called after the plugin under test. + * @param {import("webpack").Compiler} own compiler + * @returns {number[]} the stages, in the order the hook runs them + */ +const tappedStages = (own) => { + /** @type {number[]} */ + const stages = []; + + own.hooks.compilation.tap("ReadTaps", (compilation) => { + for (const tap of compilation.hooks.processAssets.taps) { + if (tap.name === "MinimizerPlugin") { + stages.push(/** @type {number} */ (tap.stage)); + } + } + }); + + return stages; +}; + class RecordStage { constructor(order, label, stage) { this.order = order; @@ -180,6 +226,87 @@ describe("where work runs", () => { expect(getErrors(stats)).toEqual([]); expect(getWarnings(stats)).toEqual([]); }); + + it("should tap once for every stage asked for, rather than once for all", async () => { + const order = []; + + new MinimizerPlugin({ + parallel: false, + minify: [ + asking(order, "minify"), + asking( + order, + "transfer", + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + ), + asking(order, "summarize", Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE), + ], + generate: { + inline: { + implementation: asking( + order, + "inline", + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + ), + type: "asset", + filename: "[path][base].inline", + }, + report: { + implementation: asking( + order, + "report", + Compilation.PROCESS_ASSETS_STAGE_REPORT, + ), + type: "asset", + filename: "[path][base].report", + }, + }, + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + // Five of them written in neither this order nor one another's, so the + // hook holds one tap per stage and runs them where each asked to be. + expect(stages).toEqual([ + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + Compilation.PROCESS_ASSETS_STAGE_REPORT, + ]); + expect(order).toEqual([ + "minify", + "inline", + "summarize", + "transfer", + "report", + ]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "one.js", + "one.js.inline", + "one.js.report", + ]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); + + it("should tap once where nothing asks for a stage of its own", async () => { + const order = []; + + new MinimizerPlugin({ + parallel: false, + minify: [asking(order, "first"), asking(order, "second")], + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + expect(stages).toEqual([Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE]); + expect(order).toEqual(["first", "second"]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); }); describe("a minimizer that asks for its own stage", () => { @@ -191,30 +318,6 @@ describe("a minimizer that asks for its own stage", () => { }); }); - /** - * @param {string[]} order where to record - * @param {string} label what to record - * @param {number=} stage the stage it asks for, if any - * @returns {EXPECTED_ANY} the minimizer - */ - const asking = (order, label, stage) => { - /** - * @param {Record} input input - * @returns {{ code: string | Buffer }} the result - */ - const run = (input) => { - order.push(label); - - return { code: Object.values(input)[0] }; - }; - - if (typeof stage === "number") { - run.getStage = () => stage; - } - - return run; - }; - it("should run where `getStage` asks, with no option given", async () => { const order = []; From df25a5713e6cb5603771b7295aa50bf476bd7b47 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 11:46:02 +0000 Subject: [PATCH 14/17] test: everything asking for one stage shares one tap Two taps where a minimizer and a generator both ask for it, since a generator reads back what a minimizer wrote and cannot run in the same pass. --- test/stage-option.test.js | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/stage-option.test.js b/test/stage-option.test.js index 393d12fb..3a81bad7 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -307,6 +307,46 @@ describe("where work runs", () => { expect(getErrors(stats)).toEqual([]); expect(getWarnings(stats)).toEqual([]); }); + + it("should share one tap between everything asking for the same stage", async () => { + const order = []; + const transfer = Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER; + + new MinimizerPlugin({ + parallel: false, + minify: [ + asking(order, "first", transfer), + asking(order, "second", transfer), + ], + generate: { + a: { + implementation: asking(order, "a", transfer), + type: "asset", + filename: "[path][base].a", + }, + b: { + implementation: asking(order, "b", transfer), + type: "asset", + filename: "[path][base].b", + }, + }, + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + // Two taps for four: one for the minimizers and one for the generators, + // which cannot share it — a generator reads what a minimizer wrote. + expect(stages).toEqual([transfer, transfer]); + expect(order).toEqual(["first", "second", "a", "b"]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "one.js", + "one.js.a", + "one.js.b", + ]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); }); describe("a minimizer that asks for its own stage", () => { From a6047aed608ef4d11b16401f5e5315b6d09287b6 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 11:55:18 +0000 Subject: [PATCH 15/17] fix: a minimizer does not read a file this plugin generated `additionalAssets` hands each pass whatever was emitted after it ran, its own generated files included, so a broad `test` had the minimizers reading back a compressed one. --- src/index.js | 46 +++++++++++++++------ test/generate-option.test.js | 80 ++++++++++++++++++++++++++++++++++++ types/index.d.ts | 7 ++++ 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/index.js b/src/index.js index 8e730ac4..09768ffe 100644 --- a/src/index.js +++ b/src/index.js @@ -644,6 +644,10 @@ class MinimizerPlugin { declaredFlags(one, "minimized"), ); + // `additionalAssets` hands this pass whatever was emitted after it ran, + // this plugin's own generated files included, and those are not to minify. + const generated = this.generatedFlags(); + /** * Remember what this plugin marked an asset with, which is what a later * pass of it reads back rather than declining. @@ -722,6 +726,12 @@ class MinimizerPlugin { return false; } + const says = /** @type {Record} */ (info); + + if (generated.some((flag) => says[flag])) { + return false; + } + if (!matchesName(name)) { return false; } @@ -1490,6 +1500,27 @@ class MinimizerPlugin { return flags; } + /** + * Every name this plugin's `asset` generators mark what they wrote with, + * which is how both passes tell a generated file from one to work on. + * @private + * @returns {string[]} the names + */ + generatedFlags() { + /** @type {string[]} */ + const flags = []; + + for (const one of this.assetGenerators()) { + for (const flag of declaredFlags(one.implementation, "generated")) { + if (!flags.includes(flag)) { + flags.push(flag); + } + } + } + + return flags; + } + /** * The generators that run over emitted assets rather than over a module as * it builds. @@ -1756,18 +1787,9 @@ class MinimizerPlugin { async generateAssets(compiler, compilation, generators, assets) { const cache = compilation.getCache("TerserWebpackPlugin|generateAssets"); const scheduled = []; - // Every name this plugin's generators work under, so none of them reads a - // file another one wrote — whichever name that one marked it with. - /** @type {string[]} */ - const produced = []; - - for (const one of this.assetGenerators()) { - for (const flag of declaredFlags(one.implementation, "generated")) { - if (!produced.includes(flag)) { - produced.push(flag); - } - } - } + // So no generator reads a file another one wrote, whichever name that one + // marked it with. + const produced = this.generatedFlags(); for (const name of Object.keys(assets)) { const asset = compilation.getAsset(name); diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 3bb54aca..2d87da09 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -2359,6 +2359,86 @@ describe("generate from an asset emitted late", () => { expect(Object.keys(stats.compilation.assets)).toContain("late.copy.txt"); expect(getErrors(stats)).toEqual([]); }); + + it("should offer each asset once, and never a file of its own making", async () => { + const minified = []; + const generated = []; + + /** + * @param {string[]} seen where to record what it was handed + * @returns {(input: { [file: string]: string | Buffer }) => { code: string | Buffer }} the function + */ + const recording = (seen) => (input) => { + const [[name, code]] = Object.entries(input); + + seen.push(name); + + return { code }; + }; + + class EmitLate { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + const { RawSource } = inner.webpack.sources; + + inner.hooks.compilation.tap("EmitLate", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "EmitLate", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (!compilation.getAsset("late.js")) { + compilation.emitAsset( + "late.js", + new RawSource("var late = 1;"), + ); + } + }, + ); + }); + } + } + + const generate = recording(generated); + + generate.getStage = ( + /** @type {typeof import("webpack").Compilation} */ compilation, + ) => compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER; + + const compiler = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + + new EmitLate().apply(compiler); + new MinimizerPlugin({ + parallel: false, + test: /.*/, + minify: recording(minified), + generate: { + implementation: generate, + type: "asset", + filename: "[path][base].gz", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Each asset once to each pass, `late.js` included — and `one.js.gz`, which + // the generator wrote, to neither: minifying it is what it is not. + expect(minified).toEqual(["one.js", "late.js"]); + expect(generated).toEqual(["one.js", "late.js"]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "late.js", + "late.js.gz", + "one.js", + "one.js.gz", + ]); + expect(getErrors(stats)).toEqual([]); + }); }); describe("generate assets, what is worth writing", () => { diff --git a/types/index.d.ts b/types/index.d.ts index 599c9429..99238f56 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -131,6 +131,13 @@ declare class MinimizerPlugin { * @returns {Set} the names */ private assetFlags; + /** + * Every name this plugin's `asset` generators mark what they wrote with, + * which is how both passes tell a generated file from one to work on. + * @private + * @returns {string[]} the names + */ + private generatedFlags; /** * The generators that run over emitted assets rather than over a module as * it builds. From b50063e664564b5043098ab67fb3e0c8a299357c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 12:45:23 +0000 Subject: [PATCH 16/17] fix: a generated file keeps nothing its name carried before webpack merges an info object into the old one, so writing over an existing name kept its immutable and sourceFilename; a function replaces instead. --- src/index.js | 10 +++++-- test/generate-option.test.js | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 09768ffe..31757db2 100644 --- a/src/index.js +++ b/src/index.js @@ -1746,10 +1746,14 @@ class MinimizerPlugin { generatedInfo.immutable = true; } - // A rebuild writes over the file it wrote last time rather than a new one, - // and what is recorded below is owed either way. + // Handed over as a function, which replaces: an object is merged into what + // the name carried before, and this file inherits nothing. if (compilation.getAsset(generatedName)) { - compilation.updateAsset(generatedName, generatedSource, generatedInfo); + compilation.updateAsset( + generatedName, + generatedSource, + () => generatedInfo, + ); } else { compilation.emitAsset(generatedName, generatedSource, generatedInfo); } diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 2d87da09..2811919a 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -2175,6 +2175,63 @@ describe("generate over a file that is already there", () => { expect(readAsset("image.copy.png", compiler, stats)).not.toBe("stale"); }); + it("should not keep what the name it wrote over promised", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, and promises for it. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + { immutable: true, sourceFilename: "somewhere/else.png" }, + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + const { info } = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.copy.png") + ); + + expect(getErrors(stats)).toEqual([]); + // What the generator says of the file it wrote, and nothing the name + // carried before it: webpack merges an info object into the old one. + expect(info.immutable).toBeUndefined(); + expect(info.sourceFilename).toBeUndefined(); + expect(info.generated).toBe(true); + }); + it("should record `related` on the original where it is kept", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"), From 214b37eedca6632e2324cf130fc9f51180e70824 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 18 Sep 2026 13:16:07 +0000 Subject: [PATCH 17/17] fix: keep a generated file written under the original's own name A generator re-encoding an asset in place names it what it was called, and deleting the original then deleted the file just written, emitting nothing. --- src/index.js | 4 +++- test/generate-option.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 31757db2..edcc2897 100644 --- a/src/index.js +++ b/src/index.js @@ -1761,7 +1761,9 @@ class MinimizerPlugin { if (generator.deleteOriginalAssets) { // Deleting an asset takes everything its `related` names with it, so // recording this file there first would delete the file just written. - if (compilation.getAsset(name)) { + // A generator writing under the original's own name leaves nothing to + // delete either: that file is now the generated one. + if (generatedName !== name && compilation.getAsset(name)) { compilation.deleteAsset(name); } diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 2811919a..2997b700 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -2351,6 +2351,35 @@ describe("deleting the asset a file was written beside", () => { expect(names).toContain("image.two.png"); expect(names).not.toContain("image.png"); }); + + it("should keep a file written under the original's own name", async () => { + const compiler = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + + new MinimizerPlugin({ + parallel: false, + test: /\.js$/i, + generate: { + implementation: (input) => ({ + code: `/* generated */${Object.values(input)[0]}`, + }), + type: "asset", + filename: "[path][base]", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Re-encoding a file in place names it what it was called, so there is no + // original left beside it to delete — only the file just written. + expect(getErrors(stats)).toEqual([]); + expect(Object.keys(stats.compilation.assets)).toEqual(["one.js"]); + expect(readAsset("one.js", compiler, stats)).toMatch( + /^\/\* generated \*\//, + ); + }); }); describe("generate from an asset emitted late", () => {