From 5eedb82855fb2a507e11ed00cf2f678b885eaf55 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 13:01:14 +0000 Subject: [PATCH 01/13] feat: named generator presets selected with `?as=` `generate` now also accepts an object of named presets. A module asks for one by name in its query (`./image.jpg?as=webp`); a module naming no preset is left alone, and an unknown preset is reported as an error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/generate-presets.md | 5 ++ README.md | 31 ++++++- src/index.js | 85 ++++++++++++++++-- src/options.json | 22 ++++- src/utils.js | 38 +++++++++ test/fixtures/preset-image.js | 4 + test/fixtures/unknown-preset.js | 4 + test/generate-option.test.js | 147 ++++++++++++++++++++++++++++++++ types/index.d.ts | 12 +++ types/utils.d.ts | 14 +++ 10 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 .changeset/generate-presets.md create mode 100644 test/fixtures/preset-image.js create mode 100644 test/fixtures/unknown-preset.js diff --git a/.changeset/generate-presets.md b/.changeset/generate-presets.md new file mode 100644 index 00000000..11e09079 --- /dev/null +++ b/.changeset/generate-presets.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +let `generate` name its generators, so an asset can pick one with `?as=` and each preset carry its own `generatorOptions` diff --git a/README.md b/README.md index 4696f079..ae2af44b 100644 --- a/README.md +++ b/README.md @@ -577,7 +577,8 @@ type generateFn = ( warnings?: (Error | string)[]; }>; -type generate = generateFn | generateFn[]; +type generate = + generateFn | generateFn[] | Record; ``` Default: `undefined` @@ -632,6 +633,33 @@ module.exports = { import webp from "./image.jpg?as=webp"; ``` +Written as an object, `generate` **names** its generators, and an asset picks +one by name with `?as=`: + +```js +new MinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generate: { + webp: MinimizerPlugin.sharpGenerate, + avif: MinimizerPlugin.sharpGenerate, + }, + // Keyed by preset name, rather than positionally, when `generate` is. + generatorOptions: { + webp: { encodeOptions: { webp: { quality: 90 } } }, + avif: { encodeOptions: { avif: { quality: 50 } } }, + }, +}); +``` + +```js +// And `./image.jpg?as=avif` for the other one. +import webp from "./image.jpg?as=webp"; +``` + +A module naming no preset is left alone, so the same build can import an image +unconverted. One naming a preset nothing defines is an error rather than a +silent decline, since the name it asked for is what the bundle would point at. + In watch mode the rename is carried on the module rather than reapplied each build, so a rebuild that does not touch the image keeps pointing at the generated name without running the generator again. Changing the image does @@ -669,6 +697,7 @@ Options for [`generate`](#generate), exactly as [`minimizerOptions`](#minimizeroptions) is for [`minify`](#minify): one object for one generator, or an array positionally matching an array of generators. A single object handed an array of generators is reused for every one of them. +Where `generate` names its generators, this is keyed by the same names. `ecma` is filled in from [`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) diff --git a/src/index.js b/src/index.js index 03c1d909..51112e25 100644 --- a/src/index.js +++ b/src/index.js @@ -15,11 +15,13 @@ const { imageminGenerate, imageminMinify, imageminNormalizeConfig, + isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, + readPreset, sharpGenerate, sharpMinify, svgoMinify, @@ -1130,6 +1132,55 @@ class TerserPlugin { }; } + /** + * The generator a module asks for by name, or the only one there is. + * + * A `generate` written as an object is a set of named presets, and `?as=` + * picks between them: a module that names none is left alone, and one that + * names a preset nothing defines is an error rather than a silent decline. + * @private + * @param {Compilation} compilation compilation + * @param {string} resource the module's resource, query and all + * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions } | undefined} the generator to run, or undefined to run none + */ + generatorFor(compilation, resource) { + const { generator } = + /** @type {{ generator: { implementation: EXPECTED_ANY, options: EXPECTED_ANY } }} */ + (this.options); + + if (!isPresets(generator.implementation)) { + return generator; + } + + const asked = readPreset(resource); + + if (!asked) { + return undefined; + } + + if ( + !Object.prototype.hasOwnProperty.call(generator.implementation, asked) + ) { + const names = Object.keys(generator.implementation); + + compilation.errors.push( + TerserPlugin.buildError( + new Error( + `Error with '${resource}': no '${asked}' preset in \`generate\`, which defines ${names.map((name) => `'${name}'`).join(", ")}.`, + ), + resource, + ), + ); + + return undefined; + } + + return { + implementation: generator.implementation[asked], + options: (generator.options || {})[asked] || {}, + }; + } + /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -1146,9 +1197,28 @@ class TerserPlugin { return; } - const implementations = Array.isArray(generator.implementation) - ? generator.implementation - : [generator.implementation]; + // Every preset, its name included: which one a build reaches is the + // asset's to decide, so all of them are part of what the pack answers for. + const implementations = []; + const presets = isPresets(generator.implementation) + ? Object.keys(generator.implementation).sort() + : undefined; + + for (const name of presets || [undefined]) { + if (typeof name !== "undefined") { + implementations.push(name); + } + + const one = + typeof name === "undefined" + ? generator.implementation + : /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (generator.implementation)[name]; + + for (const one_ of Array.isArray(one) ? one : [one]) { + implementations.push(one_); + } + } // Source rather than `getMinimizerVersion`: a generator already travels as // source, and a custom one has no version to read. const identity = getSerializeJavascript()({ @@ -1330,10 +1400,15 @@ class TerserPlugin { * @returns {Promise} the result, rewritten or as it came */ async generate(compiler, compilation, variesOn, result, module) { - const { generator } = this.options; const resource = module.resource || module.identifier(); - if (!generator || !this.matchesName(compiler, resource)) { + if (!this.options.generator || !this.matchesName(compiler, resource)) { + return result; + } + + const generator = this.generatorFor(compilation, resource); + + if (!generator) { return result; } diff --git a/src/options.json b/src/options.json index e32b9279..2f2a9b70 100644 --- a/src/options.json +++ b/src/options.json @@ -202,7 +202,7 @@ ] }, "generate": { - "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed.", + "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed. An object names its generators, and an asset asks for one of them by name with `?as=`.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generate", "anyOf": [ { @@ -214,11 +214,29 @@ "items": { "instanceof": "Function" } + }, + { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "array", + "minItems": 1, + "items": { + "instanceof": "Function" + } + } + ] + } } ] }, "generatorOptions": { - "description": "Options for the `generate` function.", + "description": "Options for the `generate` function. Keyed by preset name where `generate` names its generators.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generatoroptions", "anyOf": [ { diff --git a/src/utils.js b/src/utils.js index ab4d66fa..5ba46d75 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2228,6 +2228,42 @@ function bySpelling(parameters) { const SHARP_QUERY_PARAMETER_BY_SPELLING = bySpelling(SHARP_QUERY_PARAMETERS); +/** + * Whether `generate` was written as a set of named presets rather than as one + * generator or a pipeline of them. A function is never a set; an array is a + * pipeline, which is why only a plain object counts. + * @param {EXPECTED_ANY} generate what `generate` was set to + * @returns {boolean} true when it names its generators + */ +function isPresets(generate) { + return ( + typeof generate === "object" && + generate !== null && + !Array.isArray(generate) + ); +} + +/** + * The preset an asset's own name asks for, as `?as=webp`. + * @param {string} name asset name, query and all + * @returns {string | undefined} the preset asked for, or undefined + */ +function readPreset(name) { + const queryIndex = name.indexOf("?"); + + if (queryIndex === -1) { + return undefined; + } + + const query = name.slice(queryIndex + 1); + const fragmentIndex = query.indexOf("#"); + const asked = new URLSearchParams( + fragmentIndex === -1 ? query : query.slice(0, fragmentIndex), + ).get("as"); + + return asked || undefined; +} + /** * What an asset's name asks a minimizer for, grouped by the argument bag each * value belongs in. `output.assetModuleFilename` carries the request's query @@ -3399,12 +3435,14 @@ module.exports = { imageminGenerate, imageminMinify, imageminNormalizeConfig, + isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, packageVersion, + readPreset, replaceExtension, sharpGenerate, sharpMinify, diff --git a/test/fixtures/preset-image.js b/test/fixtures/preset-image.js new file mode 100644 index 00000000..3b53cf78 --- /dev/null +++ b/test/fixtures/preset-image.js @@ -0,0 +1,4 @@ +import jpg from "./image.jpg?as=webp"; + +// eslint-disable-next-line no-console +console.log(jpg); diff --git a/test/fixtures/unknown-preset.js b/test/fixtures/unknown-preset.js new file mode 100644 index 00000000..0759a650 --- /dev/null +++ b/test/fixtures/unknown-preset.js @@ -0,0 +1,4 @@ +import jpg from "./image.jpg?as=jxl"; + +// eslint-disable-next-line no-console +console.log(jpg); diff --git a/test/generate-option.test.js b/test/generate-option.test.js index d0638159..4f9fb79c 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -476,6 +476,153 @@ describe("imageminGenerate", () => { }); }); +describe("generate presets", () => { + /** + * @param {string} tag what it writes in front of the bytes + * @param {string} extension what the result is called + * @returns {EXPECTED_ANY} a generator that renames to `extension` + */ + function encoderNamed(tag, extension) { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ + function encode(input) { + const [[name, code]] = Object.entries(input); + + encode.calls += 1; + + return { + code: Buffer.concat([Buffer.from(`${tag}:`), Buffer.from(code)]), + filename: replaceExtension(name, extension), + }; + } + + encode.supportsBinary = () => true; + encode.supportsWorker = () => false; + encode.calls = 0; + + return encode; + } + + /** + * @param {string} entry fixture that imports the image + * @param {object} options plugin options + * @returns {Promise<{ stats: import("webpack").Stats, assets: string[] }>} what the build produced + */ + async function build(entry, options) { + const compiler = getCompiler({ + entry: path.resolve(__dirname, entry), + module: { + rules: [ + { + test: /\.(png|jpe?g|svg|webp|avif)/i, + type: "asset/resource", + generator: { filename: "[name][ext][query][fragment]" }, + }, + ], + }, + }); + + new MinimizerPlugin({ test: /\.jpe?g/i, ...options }).apply(compiler); + + const stats = await compile(compiler); + + return { stats, assets: Object.keys(stats.compilation.assets) }; + } + + it("should run the preset the asset asks for by name", async () => { + const webp = encoderNamed("WEBP", "webp"); + const avif = encoderNamed("AVIF", "avif"); + const { stats, assets } = await build("./fixtures/query-image.js", { + generate: { webp, avif }, + }); + + if (reportedNoAwait(stats)) { + return; + } + + expect(getErrors(stats)).toEqual([]); + // `query-image.js` imports `./image.jpg?w=100#frag`, which names no preset. + expect(webp.calls).toBe(0); + expect(avif.calls).toBe(0); + expect(assets).toContain("image.jpg?w=100#frag"); + }); + + it("should pick between presets and leave the others alone", async () => { + const webp = encoderNamed("WEBP", "webp"); + const avif = encoderNamed("AVIF", "avif"); + const { stats, assets } = await build("./fixtures/preset-image.js", { + generate: { webp, avif }, + }); + + if (reportedNoAwait(stats)) { + return; + } + + expect(getErrors(stats)).toEqual([]); + expect(webp.calls).toBe(1); + expect(avif.calls).toBe(0); + expect(assets).toContain("image.webp?as=webp"); + expect(assets).not.toContain("image.jpg?as=webp"); + }); + + it("should hand each preset its own options", async () => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @param {undefined} sourceMap source map + * @param {{ tag?: string }} generatorOptions the preset's options + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ + function records(input, sourceMap, generatorOptions) { + const [[name, code]] = Object.entries(input); + + records.seen.push(generatorOptions.tag); + + return { + code: Buffer.from(code), + filename: replaceExtension(name, "webp"), + }; + } + + records.supportsBinary = () => true; + records.supportsWorker = () => false; + records.seen = []; + + const { stats } = await build("./fixtures/preset-image.js", { + generate: { webp: records, avif: records }, + generatorOptions: { + webp: { tag: "for-webp" }, + avif: { tag: "for-avif" }, + }, + }); + + if (reportedNoAwait(stats)) { + return; + } + + expect(records.seen).toEqual(["for-webp"]); + }); + + it("should report a preset nothing defines", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { stats, assets } = await build("./fixtures/unknown-preset.js", { + generate: { webp }, + }); + + if (reportedNoAwait(stats)) { + return; + } + + expect(getErrors(stats).join("\n")).toContain( + "no 'jxl' preset in `generate`, which defines 'webp'", + ); + // Reported rather than guessed at: the asset is left as it was. + expect(assets).toContain("image.jpg?as=jxl"); + expect(webp.calls).toBe(0); + }); +}); + describe("generate option in watch mode", () => { let context; let watcher; diff --git a/types/index.d.ts b/types/index.d.ts index 4ff76e33..3deff4d9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -89,6 +89,18 @@ declare class TerserPlugin { * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ private embeddedMinimizer; + /** + * The generator a module asks for by name, or the only one there is. + * + * A `generate` written as an object is a set of named presets, and `?as=` + * picks between them: a module that names none is left alone, and one that + * names a preset nothing defines is an error rather than a silent decline. + * @private + * @param {Compilation} compilation compilation + * @param {string} resource the module's resource, query and all + * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions } | undefined} the generator to run, or undefined to run none + */ + private generatorFor; /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores diff --git a/types/utils.d.ts b/types/utils.d.ts index 025ee95e..a89c7558 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -336,6 +336,14 @@ export namespace imageminMinify { export function imageminNormalizeConfig( imageminConfig?: EXPECTED_OBJECT | undefined, ): Promise; +/** + * Whether `generate` was written as a set of named presets rather than as one + * generator or a pipeline of them. A function is never a set; an array is a + * pipeline, which is why only a plain object counts. + * @param {EXPECTED_ANY} generate what `generate` was set to + * @returns {boolean} true when it names its generators + */ +export function isPresets(generate: EXPECTED_ANY): boolean; /** * @param {Input} input input * @param {RawSourceMap=} sourceMap source map @@ -488,6 +496,12 @@ export namespace napiRsImageMinify { * @returns {string | undefined} its version, or undefined when it is not installed */ export function packageVersion(name: string): string | undefined; +/** + * The preset an asset's own name asks for, as `?as=webp`. + * @param {string} name asset name, query and all + * @returns {string | undefined} the preset asked for, or undefined + */ +export function readPreset(name: string): string | undefined; /** * Replace a name's extension, keeping any query and fragment: the request that * asked for the conversion is still part of what the asset is named after. From badff10ae7e94f5b8c2ca1828c7755467b3c0e9e Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 15:20:03 +0000 Subject: [PATCH 02/13] feat: generate a new asset beside an emitted one A named generator can now be written as an object carrying `type: "asset"`, `filename`, `filter` and `deleteOriginalAssets`. An `asset` generator reads what was emitted rather than a module as it builds, so nothing has to import its output and it needs no awaitable `processResult` hook. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/generate-asset-type.md | 5 + README.md | 60 ++++++- src/index.js | 270 +++++++++++++++++++++++++++++- src/options.json | 41 ++++- src/utils.js | 16 ++ test/generate-option.test.js | 173 ++++++++++++++++++- types/index.d.ts | 36 ++++ types/utils.d.ts | 7 + 8 files changed, 595 insertions(+), 13 deletions(-) create mode 100644 .changeset/generate-asset-type.md diff --git a/.changeset/generate-asset-type.md b/.changeset/generate-asset-type.md new file mode 100644 index 00000000..7a885561 --- /dev/null +++ b/.changeset/generate-asset-type.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Allow a named generator to be an object with `type: "asset"`, `filename`, `filter` and `deleteOriginalAssets`, which generates a new asset beside an emitted one. diff --git a/README.md b/README.md index ae2af44b..38f05a30 100644 --- a/README.md +++ b/README.md @@ -577,8 +577,18 @@ type generateFn = ( warnings?: (Error | string)[]; }>; +interface generator { + implementation: generateFn | generateFn[]; + type?: "import" | "asset"; + filename?: string; + filter?: (name: string) => boolean; + deleteOriginalAssets?: boolean; +} + type generate = - generateFn | generateFn[] | Record; + | generateFn + | generateFn[] + | Record; ``` Default: `undefined` @@ -660,6 +670,46 @@ A module naming no preset is left alone, so the same build can import an image unconverted. One naming a preset nothing defines is an error rather than a silent decline, since the name it asked for is what the bundle would point at. +A named generator can also be written as an object, which is what lets it read +what was **emitted** instead of a module as it builds: + +```js +new MinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generate: { + webp: { + implementation: MinimizerPlugin.sharpGenerate, + type: "asset", + // Optional. Without it the generator's own name for the result is used, + // which for `sharpGenerate` is the original with its extension replaced. + filename: "[path][name].webp", + // Optional. Narrows what this generator reads, on top of `test`. + filter: (name) => !name.includes("icons/"), + // Optional, `false` by default: the asset it read stays where it is. + deleteOriginalAssets: false, + }, + }, + generatorOptions: { webp: { encodeOptions: { webp: {} } } }, +}); +``` + +`type` decides which of the two things a generator does, and they are not +interchangeable: + +- `"import"` (the default) re-encodes a module **as it builds**, so the import + that asked for it is renamed with it. That is the only point at which a + rename can reach the bundle, and it needs the webpack noted below. +- `"asset"` writes a **new file beside one already emitted**, which nothing has + to import — a `.webp` next to a copied `.png`, say. Nothing points at it, so + `?as=` cannot reach it and it runs on any webpack. An asset already generated + is never generated from again. + +`filename` is a +[webpack filename template](https://webpack.js.org/configuration/output/#outputfilename) +resolved against the asset read, so `[path]`, `[name]`, `[base]`, `[ext]` and +`[query]` are available; content hashes are not, because the name derives from +one the original already carries. + In watch mode the rename is carried on the module rather than reapplied each build, so a rebuild that does not touch the image keeps pointing at the generated name without running the generator again. Changing the image does @@ -679,9 +729,11 @@ removed. > **Note** > -> `generate` needs a webpack whose `NormalModule` `processResult` hook can be -> awaited (**5.111** or newer). On an older webpack the plugin reports an error -> rather than silently generating nothing. +> An `"import"` generator needs a webpack whose `NormalModule` `processResult` +> hook can be awaited (**5.111** or newer), since that is where a rename has to +> happen. On an older webpack the plugin reports an error rather than silently +> generating nothing. An `"asset"` generator does not use that hook and works on +> any supported webpack. ### `generatorOptions` diff --git a/src/index.js b/src/index.js index 51112e25..88159f39 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ const { imageminGenerate, imageminMinify, imageminNormalizeConfig, + isGeneratorDescriptor, isPresets, jsonMinify, lightningCssMinify, @@ -1175,12 +1176,87 @@ class TerserPlugin { return undefined; } + const entry = generator.implementation[asked]; + const descriptor = isGeneratorDescriptor(entry) ? entry : undefined; + + // An `asset` generator runs over what is emitted, so nothing imports it + // and `?as=` cannot reach it. + if (descriptor && descriptor.type === "asset") { + return undefined; + } + return { - implementation: generator.implementation[asked], + implementation: descriptor ? descriptor.implementation : entry, options: (generator.options || {})[asked] || {}, }; } + /** + * Whether any generator rewrites a module as it builds. Only that kind needs + * `processResult` to be able to await; an `asset` generator does not. + * @private + * @returns {boolean} true when one does + */ + hasModuleGenerator() { + const { generator } = this.options; + + if (!generator) { + return false; + } + + if (!isPresets(generator.implementation)) { + return true; + } + + const presets = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (/** @type {unknown} */ (generator.implementation)); + + return Object.keys(presets).some( + (name) => + !isGeneratorDescriptor(presets[name]) || presets[name].type !== "asset", + ); + } + + /** + * The named generators that run over emitted assets rather than over a + * module as it builds. + * @private + * @returns {{ name: string, implementation: EXPECTED_ANY, options: EXPECTED_ANY, filename?: string, filter?: (name: string) => boolean, deleteOriginalAssets?: boolean }[]} them, in the order they were written + */ + assetGenerators() { + const { generator } = this.options; + + if (!generator || !isPresets(generator.implementation)) { + return []; + } + + const presets = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (/** @type {unknown} */ (generator.implementation)); + const options = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (generator.options || {}); + const found = []; + + for (const name of Object.keys(presets)) { + const entry = presets[name]; + + if (isGeneratorDescriptor(entry) && entry.type === "asset") { + found.push({ + name, + implementation: entry.implementation, + options: options[name] || {}, + filename: entry.filename, + filter: entry.filter, + deleteOriginalAssets: entry.deleteOriginalAssets, + }); + } + } + + return found; + } + /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -1215,7 +1291,21 @@ class TerserPlugin { : /** @type {{ [preset: string]: EXPECTED_ANY }} */ (generator.implementation)[name]; - for (const one_ of Array.isArray(one) ? one : [one]) { + const described = isGeneratorDescriptor(one) ? one : undefined; + const implementation = described ? described.implementation : one; + + if (described) { + implementations.push( + described.type, + described.filename, + described.filter, + described.deleteOriginalAssets, + ); + } + + for (const one_ of Array.isArray(implementation) + ? implementation + : [implementation]) { implementations.push(one_); } } @@ -1233,6 +1323,159 @@ class TerserPlugin { .slice(0, 16)}`; } + /** + * Generate one new asset from one already emitted, leaving the original in + * place unless the generator asked for it to go. + * @private + * @param {Compiler} compiler compiler + * @param {Compilation} compilation compilation + * @param {ReturnType} cache the generation cache + * @param {Asset} asset the asset to generate from + * @param {ReturnType[0]} generator the generator to run + * @returns {Promise} + */ + async generateAsset(compiler, compilation, cache, asset, generator) { + const { RawSource } = compiler.webpack.sources; + const { name, info, source } = asset; + const code = source.source(); + const input = Buffer.isBuffer(code) ? code : Buffer.from(code); + // 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( + getSerializeJavascript()({ + name, + generator: String(generator.implementation), + options: generator.options, + }), + cache.getLazyHashedEtag(source), + ); + let output = + /** @type {{ code: Buffer, filename?: string, errors?: (Error | string)[], warnings?: (Error | string)[] } | undefined} */ + (await cacheItem.getPromise()); + + if (!output) { + /** @type {MinimizedResult} */ + let generated; + + try { + generated = await minify({ + name, + input, + inputSourceMap: undefined, + minimizer: { + implementation: generator.implementation, + options: generator.options, + }, + extractComments: false, + ecma: getEcmaVersion( + /** @type {NonNullable["environment"]>} */ + (compiler.options.output.environment), + ), + }); + } catch (error) { + compilation.errors.push( + TerserPlugin.buildError( + /** @type {Error | ErrorObject | string} */ (error), + name, + ), + ); + + return; + } + + output = { + code: + typeof generated.code === "undefined" + ? input + : Buffer.isBuffer(generated.code) + ? generated.code + : Buffer.from(generated.code), + filename: generated.filename, + errors: (generated.errors || []).map((item) => + TerserPlugin.buildError( + /** @type {Error | ErrorObject | string} */ (item), + name, + ), + ), + warnings: (generated.warnings || []).map((item) => + TerserPlugin.buildWarning(item, name), + ), + }; + + await cacheItem.storePromise(output); + } + + for (const error of /** @type {Error[]} */ (output.errors || [])) { + compilation.errors.push(error); + } + + for (const warning of /** @type {Error[]} */ (output.warnings || [])) { + compilation.warnings.push(warning); + } + + const generatedName = generator.filename + ? compilation.getAssetPath(generator.filename, { filename: name }) + : output.filename || name; + const generatedSource = new RawSource(output.code); + // The derived name carries the original's hash, so what the original + // promised about its own name still holds; its sourcemap does not follow. + const generatedInfo = { ...info, generated: true }; + + delete generatedInfo.related; + + if (compilation.getAsset(generatedName)) { + compilation.updateAsset(generatedName, generatedSource, generatedInfo); + + return; + } + + compilation.emitAsset(generatedName, generatedSource, generatedInfo); + + if (generator.deleteOriginalAssets && compilation.getAsset(name)) { + compilation.deleteAsset(name); + } + } + + /** + * Generate new assets from the ones already emitted. Where `generate` + * rewrites a module's own bytes as it builds, this adds a file beside one + * that is already named, so nothing has to import it. + * @private + * @param {Compiler} compiler compiler + * @param {Compilation} compilation compilation + * @returns {Promise} + */ + async generateAssets(compiler, compilation) { + const generators = this.assetGenerators(); + + if (generators.length === 0) { + return; + } + + const cache = compilation.getCache("TerserWebpackPlugin|generateAssets"); + const scheduled = []; + + for (const name of Object.keys(compilation.assets)) { + const asset = compilation.getAsset(name); + + if (!asset || asset.info.generated || !this.matchesName(compiler, name)) { + continue; + } + + for (const generator of generators) { + if (generator.filter && !generator.filter(name)) { + continue; + } + + scheduled.push( + this.generateAsset(compiler, compilation, cache, asset, generator), + ); + } + } + + await Promise.all(scheduled); + } + /** * Minify one source a module embeds in another language's output — CSS or * HTML reaching the bundle inside a JavaScript string literal, an @@ -1621,15 +1864,19 @@ class TerserPlugin { }); } - if (this.options.generator) { + const moduleGenerator = this.hasModuleGenerator() + ? this.options.generator + : undefined; + + if (moduleGenerator) { const generatorData = getSerializeJavascript()({ - generator: Array.isArray(this.options.generator.implementation) - ? this.options.generator.implementation.map(getVersion) + generator: Array.isArray(moduleGenerator.implementation) + ? moduleGenerator.implementation.map(getVersion) : getVersion( /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ - (this.options.generator.implementation), + (moduleGenerator.implementation), ), - options: this.options.generator.options, + options: moduleGenerator.options, }); const variesOn = new compiler.webpack.sources.RawSource(generatorData); const moduleHooks = @@ -1672,6 +1919,15 @@ class TerserPlugin { }), ); + compilation.hooks.processAssets.tapPromise( + { + name: pluginName, + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, + }, + () => this.generateAssets(compiler, compilation), + ); + compilation.hooks.statsPrinter.tap(pluginName, (stats) => { stats.hooks.print .for("asset.info.minimized") diff --git a/src/options.json b/src/options.json index 2f2a9b70..edfdb303 100644 --- a/src/options.json +++ b/src/options.json @@ -202,7 +202,7 @@ ] }, "generate": { - "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed. An object names its generators, and an asset asks for one of them by name with `?as=`.", + "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed. An object names its generators, and an asset asks for one of them by name with `?as=`; a named generator written as an object can instead generate a new asset beside an emitted one.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generate", "anyOf": [ { @@ -229,6 +229,45 @@ "items": { "instanceof": "Function" } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The generator itself.", + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "array", + "minItems": 1, + "items": { + "instanceof": "Function" + } + } + ] + }, + "type": { + "description": "`import` re-encodes a module as it is built, so the import that asked for it is renamed with it. `asset` generates a new asset beside an emitted one, which nothing has to import.", + "enum": ["import", "asset"] + }, + "filename": { + "description": "Name for the generated asset, as a webpack filename template. `asset` generators only; by default the generator's own name for it is used.", + "type": "string", + "minLength": 1 + }, + "filter": { + "description": "Decides per asset whether to generate from it, on top of `test`/`include`/`exclude`.", + "instanceof": "Function" + }, + "deleteOriginalAssets": { + "description": "Removes the asset generated from. `asset` generators only.", + "type": "boolean" + } + }, + "required": ["implementation"] } ] } diff --git a/src/utils.js b/src/utils.js index 5ba46d75..5fd32df8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2243,6 +2243,21 @@ function isPresets(generate) { ); } +/** + * Whether one named generator was written as an object stating how to run it, + * rather than as the generator itself. + * @param {EXPECTED_ANY} entry one entry of a `generate` preset object + * @returns {boolean} true when it describes a generator + */ +function isGeneratorDescriptor(entry) { + return ( + typeof entry === "object" && + entry !== null && + !Array.isArray(entry) && + typeof entry.implementation !== "undefined" + ); +} + /** * The preset an asset's own name asks for, as `?as=webp`. * @param {string} name asset name, query and all @@ -3435,6 +3450,7 @@ module.exports = { imageminGenerate, imageminMinify, imageminNormalizeConfig, + isGeneratorDescriptor, isPresets, jsonMinify, lightningCssMinify, diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 4f9fb79c..3c7d7f7d 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -5,7 +5,13 @@ import path from "path"; import MinimizerPlugin from "../src"; import { replaceExtension } from "../src/utils"; -import { compile, getCompiler, getErrors, getWarnings } from "./helpers"; +import { + compile, + getCompiler, + getErrors, + getWarnings, + readAsset, +} from "./helpers"; import { RUN_IMAGE_TESTS } from "./helpers/env"; /** @@ -623,6 +629,171 @@ describe("generate presets", () => { }); }); +describe("generate assets", () => { + /** + * An encoder that reports how often it ran, so a test can tell "declined" from + * "ran and produced the same name". + * @param {string} tag bytes it prefixes its output with + * @param {string} extension extension it re-encodes to + * @returns {EXPECTED_ANY} the encoder + */ + function encoderNamed(tag, extension) { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ + function encode(input) { + const [[name, code]] = Object.entries(input); + + encode.calls += 1; + + return { + code: Buffer.concat([Buffer.from(`${tag}:`), Buffer.from(code)]), + filename: replaceExtension(name, extension), + }; + } + + encode.supportsBinary = () => true; + encode.supportsWorker = () => false; + encode.calls = 0; + + return encode; + } + + /** + * @param {object} options plugin options + * @returns {Promise<{ stats: import("webpack").Stats, assets: string[] }>} what the build produced + */ + async function build(options) { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ test: /\.jpe?g$/i, ...options }).apply(compiler); + + const stats = await compile(compiler); + + return { compiler, stats, assets: Object.keys(stats.compilation.assets) }; + } + + it("should generate a new asset beside the one it read", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { compiler, stats, assets } = await build({ + generate: { webp: { implementation: webp, type: "asset" } }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + expect(webp.calls).toBe(1); + expect(assets).toContain("image.webp"); + expect(assets).toContain("image.jpg"); + expect(readAsset("image.webp", compiler, stats).toString()).toMatch( + /^WEBP:/, + ); + }); + + it("should name the generated asset with `filename` when given one", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + filename: "generated/[name].webp", + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(assets).toContain("generated/image.webp"); + expect(assets).not.toContain("image.webp"); + }); + + it("should remove the original with `deleteOriginalAssets`", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + deleteOriginalAssets: true, + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(assets).toContain("image.webp"); + expect(assets).not.toContain("image.jpg"); + }); + + it("should skip an asset its `filter` declines", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + filter: (name) => !name.endsWith(".jpg"), + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(webp.calls).toBe(0); + expect(assets).not.toContain("image.webp"); + expect(assets).toContain("image.jpg"); + }); + + it("should generate every asset generator asked for, from one asset", async () => { + const webp = encoderNamed("WEBP", "webp"); + const avif = encoderNamed("AVIF", "avif"); + const { stats, assets } = await build({ + generate: { + webp: { implementation: webp, type: "asset" }, + avif: { implementation: avif, type: "asset" }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(webp.calls).toBe(1); + expect(avif.calls).toBe(1); + expect(assets).toContain("image.webp"); + expect(assets).toContain("image.avif"); + expect(assets).toContain("image.jpg"); + }); + + it("should not let `?as=` reach an asset generator", async () => { + const webp = encoderNamed("WEBP", "webp"); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/preset-image.js"), + module: { + rules: [ + { + test: /\.(png|jpe?g|svg|webp|avif)/i, + type: "asset/resource", + generator: { filename: "[name][ext][query][fragment]" }, + }, + ], + }, + }); + + new MinimizerPlugin({ + test: /\.jpe?g/i, + generate: { webp: { implementation: webp, type: "asset" } }, + }).apply(compiler); + + const stats = await compile(compiler); + const assets = Object.keys(stats.compilation.assets); + + expect(getErrors(stats)).toEqual([]); + // The import named the preset, but an `asset` generator reads what was + // emitted, so the module keeps its own name and the new file sits beside it. + expect(assets).toContain("image.jpg?as=webp"); + expect(assets).toContain("image.webp?as=webp"); + }); +}); + describe("generate option in watch mode", () => { let context; let watcher; diff --git a/types/index.d.ts b/types/index.d.ts index 3deff4d9..b3332e71 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -101,6 +101,20 @@ declare class TerserPlugin { * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions } | undefined} the generator to run, or undefined to run none */ private generatorFor; + /** + * Whether any generator rewrites a module as it builds. Only that kind needs + * `processResult` to be able to await; an `asset` generator does not. + * @private + * @returns {boolean} true when one does + */ + private hasModuleGenerator; + /** + * The named generators that run over emitted assets rather than over a + * module as it builds. + * @private + * @returns {{ name: string, implementation: EXPECTED_ANY, options: EXPECTED_ANY, filename?: string, filter?: (name: string) => boolean, deleteOriginalAssets?: boolean }[]} them, in the order they were written + */ + private assetGenerators; /** * Carries the generator's identity into the persistent cache's version. * A generator rewrites a module's own build result, which the pack restores @@ -110,6 +124,28 @@ declare class TerserPlugin { * @returns {void} */ private saltCacheVersion; + /** + * Generate one new asset from one already emitted, leaving the original in + * place unless the generator asked for it to go. + * @private + * @param {Compiler} compiler compiler + * @param {Compilation} compilation compilation + * @param {ReturnType} cache the generation cache + * @param {Asset} asset the asset to generate from + * @param {ReturnType[0]} generator the generator to run + * @returns {Promise} + */ + private generateAsset; + /** + * Generate new assets from the ones already emitted. Where `generate` + * rewrites a module's own bytes as it builds, this adds a file beside one + * that is already named, so nothing has to import it. + * @private + * @param {Compiler} compiler compiler + * @param {Compilation} compilation compilation + * @returns {Promise} + */ + private generateAssets; /** * Minify one source a module embeds in another language's output — CSS or * HTML reaching the bundle inside a JavaScript string literal, an diff --git a/types/utils.d.ts b/types/utils.d.ts index a89c7558..1a737b44 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -336,6 +336,13 @@ export namespace imageminMinify { export function imageminNormalizeConfig( imageminConfig?: EXPECTED_OBJECT | undefined, ): Promise; +/** + * Whether one named generator was written as an object stating how to run it, + * rather than as the generator itself. + * @param {EXPECTED_ANY} entry one entry of a `generate` preset object + * @returns {boolean} true when it describes a generator + */ +export function isGeneratorDescriptor(entry: EXPECTED_ANY): boolean; /** * Whether `generate` was written as a set of named presets rather than as one * generator or a pipeline of them. A function is never a set; an array is a From 3e26470ea7ebb8e10f436a164cdeb180d53aa688 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 16:46:40 +0000 Subject: [PATCH 03/13] feat: `[width]` and `[height]` in an asset generator's `filename` `sharp` already knows what it encoded, so it reports the size and the name can read it. A placeholder no generator reports a size for is an error rather than a file literally called `[width]`. Also documents migrating from `image-minimizer-webpack-plugin`, option by option. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/generate-asset-size.md | 5 ++ .cspell.json | 5 +- README.md | 80 +++++++++++++++++++++++++++++++ src/index.js | 27 ++++++++++- src/minify.js | 14 ++++++ src/utils.js | 30 ++++++++++-- test/generate-option.test.js | 40 ++++++++++++++++ types/index.d.ts | 8 ++++ types/utils.d.ts | 15 ++++++ 9 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 .changeset/generate-asset-size.md diff --git a/.changeset/generate-asset-size.md b/.changeset/generate-asset-size.md new file mode 100644 index 00000000..ef8d3f98 --- /dev/null +++ b/.changeset/generate-asset-size.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Support `[width]` and `[height]` in an `asset` generator's `filename`, and document migrating from `image-minimizer-webpack-plugin`. diff --git a/.cspell.json b/.cspell.json index 7d7c7a99..aa754b33 100644 --- a/.cspell.json +++ b/.cspell.json @@ -6,8 +6,8 @@ "apng", "autocrlf", "CAAC", - "catmull", "CACH", + "catmull", "chunkhash", "commitlint", "cssnano", @@ -40,8 +40,8 @@ "imagemin", "jpegtran", "jridgewell", - "lanczos", "KAAIC", + "lanczos", "libvips", "lightningcss", "MAAOC", @@ -65,6 +65,7 @@ "SAAUE", "sourcefile", "sourcesContent", + "squoosh", "srcdoc", "stringifier", "svgo", diff --git a/README.md b/README.md index 38f05a30..f228ad4d 100644 --- a/README.md +++ b/README.md @@ -2430,6 +2430,86 @@ module.exports = { }; ``` +## Migrating from `image-minimizer-webpack-plugin` + +This plugin does what +[`image-minimizer-webpack-plugin`](https://github.com/webpack/image-minimizer-webpack-plugin) +did, for every asset type rather than images alone, so one plugin covers a +build instead of two. The options line up like this: + +| `image-minimizer-webpack-plugin` | here | +| ---------------------------------- | -------------------------------------------------------- | +| `minimizer.implementation` | [`minify`](#minify) | +| `minimizer.options` | [`minimizerOptions`](#minimizeroptions) | +| `minimizer.filter` | a `filter` on the minimizer itself | +| `generator[].implementation` | [`generate`](#generate) | +| `generator[].options` | [`generatorOptions`](#generatoroptions), keyed by preset | +| `generator[].preset` | the key the generator is written under | +| `generator[].type` | `type` on that generator | +| `generator[].filename` / `.filter` | `filename` / `filter` on that generator | +| `deleteOriginalAssets` | `deleteOriginalAssets` on that generator | +| `concurrency` | [`parallel`](#parallel) | +| `test` / `include` / `exclude` | unchanged | +| `loader` | nothing — `generate` reaches a module without one | +| `severityError` | nothing — a failed minimizer is an error | + +The minimizers and generators keep their names — +`imageminMinify`, `imageminGenerate`, `imageminNormalizeConfig`, `sharpMinify`, +`sharpGenerate`, `svgoMinify` — so only the plugin they are read off changes. +`squooshMinify` and `squooshGenerate` are not carried over: `@squoosh/lib` is +unmaintained, and `image-minimizer-webpack-plugin` already marked both +deprecated. + +Two shapes changed rather than moved. Generators are **named** here instead of +listed, because a name is what `?as=` asks for, so an array of two generators +becomes an object of two entries. And a generator that produced a file beside +the original is written with `type: "asset"`, which is the default there and +not here — without it a generator re-encodes the module itself, which is what +lets the import be renamed. + +**Before** + +```js +const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); + +module.exports = { + plugins: [ + new ImageMinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generator: [ + { + type: "asset", + preset: "webp", + implementation: ImageMinimizerPlugin.sharpGenerate, + options: { encodeOptions: { webp: { quality: 90 } } }, + }, + ], + }), + ], +}; +``` + +**After** + +```js +const MinimizerPlugin = require("minimizer-webpack-plugin"); + +module.exports = { + plugins: [ + new MinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generate: { + webp: { + type: "asset", + implementation: MinimizerPlugin.sharpGenerate, + }, + }, + generatorOptions: { webp: { encodeOptions: { webp: { quality: 90 } } } }, + }), + ], +}; +``` + ## Contributing We welcome all contributions! diff --git a/src/index.js b/src/index.js index 88159f39..d32b8c68 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ const { imageminGenerate, imageminMinify, imageminNormalizeConfig, + interpolateSize, isGeneratorDescriptor, isPresets, jsonMinify, @@ -126,6 +127,8 @@ const { * @typedef {object} MinimizedResult * @property {(string | Buffer)=} code code — a `Buffer` from a minimizer that declares `supportsBinary` * @property {string=} filename the name the result should carry, when re-encoding it changed what the bytes are. Only the `generate` path can honour it: an asset is named while its module is built, before anything downstream refers to it + * @property {number=} width what the result is now wide, where re-encoding knows it. `[width]` in an `asset` generator's `filename` reads it + * @property {number=} height what the result is now tall, where re-encoding knows it * @property {RawSourceMap=} map source map * @property {(Error | string)[]=} errors errors * @property {(Error | string)[]=} warnings warnings @@ -1350,7 +1353,7 @@ class TerserPlugin { cache.getLazyHashedEtag(source), ); let output = - /** @type {{ code: Buffer, filename?: string, errors?: (Error | string)[], warnings?: (Error | string)[] } | undefined} */ + /** @type {{ code: Buffer, filename?: string, width?: number, height?: number, errors?: (Error | string)[], warnings?: (Error | string)[] } | undefined} */ (await cacheItem.getPromise()); if (!output) { @@ -1391,6 +1394,8 @@ class TerserPlugin { ? generated.code : Buffer.from(generated.code), filename: generated.filename, + width: generated.width, + height: generated.height, errors: (generated.errors || []).map((item) => TerserPlugin.buildError( /** @type {Error | ErrorObject | string} */ (item), @@ -1414,8 +1419,26 @@ class TerserPlugin { } const generatedName = generator.filename - ? compilation.getAssetPath(generator.filename, { filename: name }) + ? interpolateSize( + compilation.getAssetPath(generator.filename, { filename: name }), + output, + ) : output.filename || name; + + // A size the generator never reported leaves its placeholder standing, and + // a file named `[width]` is worse than a build that says why. + if (/\[(width|height)\]/i.test(generatedName)) { + compilation.errors.push( + TerserPlugin.buildError( + new Error( + `Error with '${name}': '${generator.filename}' asks for a size this generator does not report.`, + ), + name, + ), + ); + + return; + } const generatedSource = new RawSource(output.code); // The derived name carries the original's hash, so what the original // promised about its own name still holds; its sourcemap does not follow. diff --git a/src/minify.js b/src/minify.js index c2b9b5b8..e704e430 100644 --- a/src/minify.js +++ b/src/minify.js @@ -316,6 +316,8 @@ async function minify(options) { let lastCode; /** @type {string | undefined} */ let lastFilename; + let lastWidth; + let lastHeight; /** @type {RawSourceMap | undefined} */ let lastMap; /** @type {(Error | string)[]} */ @@ -519,6 +521,16 @@ async function minify(options) { lastFilename = result.filename; } + // The size it now has, where the re-encoder knows it. Last answer wins, + // as the name does. + if (typeof result.width === "number") { + lastWidth = result.width; + } + + if (typeof result.height === "number") { + lastHeight = result.height; + } + if (typeof result.code === "string" || Buffer.isBuffer(result.code)) { lastCode = result.code; // The minimizer's output map is `name → step-output`. Chain it with @@ -535,6 +547,8 @@ async function minify(options) { // Only when one was named: almost nothing re-encodes into another format, // and an always-present `filename: undefined` is in every result's shape. ...(typeof lastFilename === "string" ? { filename: lastFilename } : {}), + ...(typeof lastWidth === "number" ? { width: lastWidth } : {}), + ...(typeof lastHeight === "number" ? { height: lastHeight } : {}), map: lastMap, warnings, errors, diff --git a/src/utils.js b/src/utils.js index 5fd32df8..f6a1d853 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2243,6 +2243,22 @@ function isPresets(generate) { ); } +/** + * Substitutes `[width]` and `[height]` into a name, which webpack's own + * templates do not know. A placeholder no generator reported a size for is + * left standing, so the caller can tell it apart from a name it can use. + * @param {string} filename a filename template, already otherwise resolved + * @param {{ width?: number, height?: number }} size what the generator reported + * @returns {string} the name + */ +function interpolateSize(filename, size) { + return filename.replace(/\[(width|height)\]/gi, (placeholder, key) => { + const value = size[/** @type {"width" | "height"} */ (key.toLowerCase())]; + + return typeof value === "number" ? String(value) : placeholder; + }); +} + /** * Whether one named generator was written as an object stating how to run it, * rather than as the generator itself. @@ -2529,11 +2545,18 @@ async function sharpTransform(input, minimizerOptions, targetFormat) { ), ); - const encoded = await pipeline.toBuffer(); + const { data: encoded, info } = await pipeline.toBuffer({ + resolveWithObject: true, + }); + const size = { width: info.width, height: info.height }; return targetFormat - ? { code: encoded, filename: replaceExtension(name, targetFormat) } - : { code: encoded }; + ? { + ...size, + code: encoded, + filename: replaceExtension(name, targetFormat), + } + : { ...size, code: encoded }; } /* istanbul ignore next */ @@ -3450,6 +3473,7 @@ module.exports = { imageminGenerate, imageminMinify, imageminNormalizeConfig, + interpolateSize, isGeneratorDescriptor, isPresets, jsonMinify, diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 3c7d7f7d..2deb7cef 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -648,6 +648,7 @@ describe("generate assets", () => { encode.calls += 1; return { + ...encode.reports, code: Buffer.concat([Buffer.from(`${tag}:`), Buffer.from(code)]), filename: replaceExtension(name, extension), }; @@ -656,6 +657,7 @@ describe("generate assets", () => { encode.supportsBinary = () => true; encode.supportsWorker = () => false; encode.calls = 0; + encode.reports = {}; return encode; } @@ -710,6 +712,44 @@ describe("generate assets", () => { expect(assets).not.toContain("image.webp"); }); + it("should fill `[width]` and `[height]` from what the generator reports", async () => { + const webp = encoderNamed("WEBP", "webp"); + + webp.reports = { width: 320, height: 200 }; + + const { stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + filename: "[name]-[width]x[height].webp", + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(assets).toContain("image-320x200.webp"); + }); + + it("should error when `filename` asks for a size the generator does not report", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + filename: "[name]-[width].webp", + }, + }, + }); + + expect(getErrors(stats)).toHaveLength(1); + expect(getErrors(stats)[0]).toMatch( + /asks for a size this generator does not report/, + ); + expect(assets).not.toContain("image-[width].webp"); + }); + it("should remove the original with `deleteOriginalAssets`", async () => { const webp = encoderNamed("WEBP", "webp"); const { stats, assets } = await build({ diff --git a/types/index.d.ts b/types/index.d.ts index b3332e71..65caebae 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -431,6 +431,14 @@ type MinimizedResult = { * the name the result should carry, when re-encoding it changed what the bytes are. Only the `generate` path can honour it: an asset is named while its module is built, before anything downstream refers to it */ filename?: string | undefined; + /** + * what the result is now wide, where re-encoding knows it. `[width]` in an `asset` generator's `filename` reads it + */ + width?: number | undefined; + /** + * what the result is now tall, where re-encoding knows it + */ + height?: number | undefined; /** * source map */ diff --git a/types/utils.d.ts b/types/utils.d.ts index 1a737b44..d5c2fdb2 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -336,6 +336,21 @@ export namespace imageminMinify { export function imageminNormalizeConfig( imageminConfig?: EXPECTED_OBJECT | undefined, ): Promise; +/** + * Substitutes `[width]` and `[height]` into a name, which webpack's own + * templates do not know. A placeholder no generator reported a size for is + * left standing, so the caller can tell it apart from a name it can use. + * @param {string} filename a filename template, already otherwise resolved + * @param {{ width?: number, height?: number }} size what the generator reported + * @returns {string} the name + */ +export function interpolateSize( + filename: string, + size: { + width?: number; + height?: number; + }, +): string; /** * Whether one named generator was written as an object stating how to run it, * rather than as the generator itself. From 336fe1a340a515da3acc374b94856962f955998c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 18:20:42 +0000 Subject: [PATCH 04/13] feat: take a generator's options from `generate` itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generator written as an object now carries its own `options`, so one generator is configured in one place. `generatorOptions` is deprecated but keeps working, the way `terserOptions` does for `minimizerOptions`. Two things that used to pass silently are now errors: options given in both places for one generator, and a `generatorOptions` key naming no generator — which used to mean the generator ran with no options at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/generate-own-options.md | 5 + README.md | 19 +- src/index.js | 291 ++++++++++++++++------------- src/options.json | 53 +++++- test/generate-option.test.js | 123 ++++++++++++ types/index.d.ts | 35 +++- 6 files changed, 389 insertions(+), 137 deletions(-) create mode 100644 .changeset/generate-own-options.md diff --git a/.changeset/generate-own-options.md b/.changeset/generate-own-options.md new file mode 100644 index 00000000..ff10f0b9 --- /dev/null +++ b/.changeset/generate-own-options.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Take a generator's options from `generate` itself, deprecating `generatorOptions`; giving both for one generator, or naming no generator, is now an error. diff --git a/README.md b/README.md index f228ad4d..1238caaa 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,7 @@ type generateFn = ( interface generator { implementation: generateFn | generateFn[]; + options?: Record; type?: "import" | "asset"; filename?: string; filter?: (name: string) => boolean; @@ -588,6 +589,7 @@ interface generator { type generate = | generateFn | generateFn[] + | generator | Record; ``` @@ -670,8 +672,9 @@ A module naming no preset is left alone, so the same build can import an image unconverted. One naming a preset nothing defines is an error rather than a silent decline, since the name it asked for is what the bundle would point at. -A named generator can also be written as an object, which is what lets it read -what was **emitted** instead of a module as it builds: +A generator can also be written as an object — on its own, or under a name — +which is where its own options live and what lets it read what was **emitted** +instead of a module as it builds: ```js new MinimizerPlugin({ @@ -679,6 +682,9 @@ new MinimizerPlugin({ generate: { webp: { implementation: MinimizerPlugin.sharpGenerate, + // Where this generator's options belong. `generatorOptions` still works + // but is deprecated, and setting both for one generator is an error. + options: { encodeOptions: { webp: {} } }, type: "asset", // Optional. Without it the generator's own name for the result is used, // which for `sharpGenerate` is the original with its extension replaced. @@ -689,7 +695,6 @@ new MinimizerPlugin({ deleteOriginalAssets: false, }, }, - generatorOptions: { webp: { encodeOptions: { webp: {} } } }, }); ``` @@ -745,6 +750,14 @@ type generatorOptions = Record | Record[]; Default: `{}` +> **Note** +> +> `generatorOptions` is deprecated in favour of a generator's own `options`, +> which keeps one generator's configuration in one place — see +> [`generate`](#generate). It keeps working; setting both for the same +> generator is an error, and a key naming no generator is an error rather than +> silently doing nothing. + Options for [`generate`](#generate), exactly as [`minimizerOptions`](#minimizeroptions) is for [`minify`](#minify): one object for one generator, or an array positionally matching an array of generators. A diff --git a/src/index.js b/src/index.js index d32b8c68..f575ef29 100644 --- a/src/index.js +++ b/src/index.js @@ -1136,61 +1136,113 @@ class TerserPlugin { }; } + /** + * One generator, however it was written: as the generator itself or as an + * object stating how to run it. + * @private + * @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 + */ + describeGenerator(name, entry, declared) { + const descriptor = isGeneratorDescriptor(entry) ? entry : undefined; + const own = descriptor ? descriptor.options : undefined; + + return { + name, + implementation: descriptor ? descriptor.implementation : entry, + options: (typeof own === "undefined" ? declared : own) || {}, + type: descriptor ? descriptor.type : undefined, + filename: descriptor ? descriptor.filename : undefined, + filter: descriptor ? descriptor.filter : undefined, + deleteOriginalAssets: descriptor + ? descriptor.deleteOriginalAssets + : undefined, + }; + } + + /** + * Every generator `generate` holds, whichever shape it was written in. + * @private + * @returns {ReturnType[]} them, in the order they were written + */ + generators() { + const { generator } = this.options; + + if (!generator) { + return []; + } + + const written = generator.implementation; + const declared = generator.options; + + if (!isPresets(written) || isGeneratorDescriptor(written)) { + return [this.describeGenerator(undefined, written, declared)]; + } + + const presets = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (/** @type {unknown} */ (written)); + const perPreset = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (declared || {}); + + return Object.keys(presets).map((preset) => + this.describeGenerator(preset, presets[preset], perPreset[preset]), + ); + } + /** * The generator a module asks for by name, or the only one there is. * - * A `generate` written as an object is a set of named presets, and `?as=` - * picks between them: a module that names none is left alone, and one that - * names a preset nothing defines is an error rather than a silent decline. + * A `generate` naming its generators is picked between by `?as=`: a module + * that names none is left alone, and one that names a generator nothing + * defines is an error rather than a silent decline. * @private * @param {Compilation} compilation compilation * @param {string} resource the module's resource, query and all * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions } | undefined} the generator to run, or undefined to run none */ generatorFor(compilation, resource) { - const { generator } = - /** @type {{ generator: { implementation: EXPECTED_ANY, options: EXPECTED_ANY } }} */ - (this.options); - - if (!isPresets(generator.implementation)) { - return generator; - } + const found = this.generators(); + const named = found.filter((one) => typeof one.name !== "undefined"); + let generator; - const asked = readPreset(resource); + if (named.length === 0) { + [generator] = found; + } else { + const asked = readPreset(resource); - if (!asked) { - return undefined; - } + if (!asked) { + return undefined; + } - if ( - !Object.prototype.hasOwnProperty.call(generator.implementation, asked) - ) { - const names = Object.keys(generator.implementation); + generator = named.find((one) => one.name === asked); - compilation.errors.push( - TerserPlugin.buildError( - new Error( - `Error with '${resource}': no '${asked}' preset in \`generate\`, which defines ${names.map((name) => `'${name}'`).join(", ")}.`, + if (!generator) { + compilation.errors.push( + TerserPlugin.buildError( + new Error( + `Error with '${resource}': no '${asked}' preset in \`generate\`, which defines ${named.map((one) => `'${one.name}'`).join(", ")}.`, + ), + resource, ), - resource, - ), - ); + ); - return undefined; + return undefined; + } } - const entry = generator.implementation[asked]; - const descriptor = isGeneratorDescriptor(entry) ? entry : undefined; - // An `asset` generator runs over what is emitted, so nothing imports it // and `?as=` cannot reach it. - if (descriptor && descriptor.type === "asset") { + if (!generator || generator.type === "asset") { return undefined; } return { - implementation: descriptor ? descriptor.implementation : entry, - options: (generator.options || {})[asked] || {}, + implementation: generator.implementation, + options: generator.options, }; } @@ -1201,63 +1253,17 @@ class TerserPlugin { * @returns {boolean} true when one does */ hasModuleGenerator() { - const { generator } = this.options; - - if (!generator) { - return false; - } - - if (!isPresets(generator.implementation)) { - return true; - } - - const presets = - /** @type {{ [preset: string]: EXPECTED_ANY }} */ - (/** @type {unknown} */ (generator.implementation)); - - return Object.keys(presets).some( - (name) => - !isGeneratorDescriptor(presets[name]) || presets[name].type !== "asset", - ); + return this.generators().some((one) => one.type !== "asset"); } /** - * The named generators that run over emitted assets rather than over a - * module as it builds. + * The generators that run over emitted assets rather than over a module as + * it builds. * @private - * @returns {{ name: string, implementation: EXPECTED_ANY, options: EXPECTED_ANY, filename?: string, filter?: (name: string) => boolean, deleteOriginalAssets?: boolean }[]} them, in the order they were written + * @returns {ReturnType[]} them, in the order they were written */ assetGenerators() { - const { generator } = this.options; - - if (!generator || !isPresets(generator.implementation)) { - return []; - } - - const presets = - /** @type {{ [preset: string]: EXPECTED_ANY }} */ - (/** @type {unknown} */ (generator.implementation)); - const options = - /** @type {{ [preset: string]: EXPECTED_ANY }} */ - (generator.options || {}); - const found = []; - - for (const name of Object.keys(presets)) { - const entry = presets[name]; - - if (isGeneratorDescriptor(entry) && entry.type === "asset") { - found.push({ - name, - implementation: entry.implementation, - options: options[name] || {}, - filename: entry.filename, - filter: entry.filter, - deleteOriginalAssets: entry.deleteOriginalAssets, - }); - } - } - - return found; + return this.generators().filter((one) => one.type === "asset"); } /** @@ -1276,52 +1282,28 @@ class TerserPlugin { return; } - // Every preset, its name included: which one a build reaches is the - // asset's to decide, so all of them are part of what the pack answers for. - const implementations = []; - const presets = isPresets(generator.implementation) - ? Object.keys(generator.implementation).sort() - : undefined; - - for (const name of presets || [undefined]) { - if (typeof name !== "undefined") { - implementations.push(name); - } - - const one = - typeof name === "undefined" - ? generator.implementation - : /** @type {{ [preset: string]: EXPECTED_ANY }} */ - (generator.implementation)[name]; - - const described = isGeneratorDescriptor(one) ? one : undefined; - const implementation = described ? described.implementation : one; - - if (described) { - implementations.push( - described.type, - described.filename, - described.filter, - described.deleteOriginalAssets, - ); - } - - for (const one_ of Array.isArray(implementation) - ? implementation - : [implementation]) { - implementations.push(one_); - } - } - // Source rather than `getMinimizerVersion`: a generator already travels as - // source, and a custom one has no version to read. - const identity = getSerializeJavascript()({ - generator: implementations.map(String), - options: generator.options, - }); + // Every generator, its name and how it is run included: which one a build + // reaches is the asset's to decide, so all are part of the pack's answer. + const identity = [...this.generators()] + .sort((a, b) => String(a.name).localeCompare(String(b.name))) + .map((one) => [ + one.name, + // Source rather than `getMinimizerVersion`: a generator already + // travels as source, and a custom one has no version to read. + (Array.isArray(one.implementation) + ? one.implementation + : [one.implementation] + ).map(String), + one.type, + one.filename, + String(one.filter), + one.deleteOriginalAssets, + one.options, + ]); cache.version = `${cache.version || ""}|TerserPlugin-generate-${crypto .createHash("sha256") - .update(identity) + .update(getSerializeJavascript()(identity)) .digest("hex") .slice(0, 16)}`; } @@ -1766,6 +1748,61 @@ class TerserPlugin { return [output.code, result[1], result[2]]; } + /** + * Cross-field checks the schema cannot make: options given twice for one + * generator, and a `generatorOptions` key naming no generator. + * @private + * @returns {void} + */ + validateGenerators() { + const { generator } = this.options; + + if (!generator) { + return; + } + + const written = generator.implementation; + const declared = generator.options; + const named = isPresets(written) && !isGeneratorDescriptor(written); + const presets = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (/** @type {unknown} */ (written)); + const perPreset = + /** @type {{ [preset: string]: EXPECTED_ANY }} */ + (declared); + + for (const name of named ? Object.keys(presets) : [undefined]) { + const entry = typeof name === "undefined" ? written : presets[name]; + const own = isGeneratorDescriptor(entry) ? entry.options : undefined; + const twice = + typeof name === "undefined" ? declared : perPreset && perPreset[name]; + + if (typeof own !== "undefined" && typeof twice !== "undefined") { + throw new Error( + typeof name === "undefined" + ? "`generate` sets its own `options`, and the deprecated `generatorOptions` sets them too. Keep the one in `generate`." + : `The '${name}' generator in \`generate\` sets its own \`options\`, and the deprecated \`generatorOptions.${name}\` sets them too. Keep the one in \`generate\`.`, + ); + } + } + + if (!named || !isPresets(declared)) { + return; + } + + for (const name of Object.keys(perPreset)) { + if (!Object.prototype.hasOwnProperty.call(presets, name)) { + throw new Error( + `\`generatorOptions.${name}\` names no generator in \`generate\`, which defines ${Object.keys( + presets, + ) + .map((one) => `'${one}'`) + .join(", ")}.`, + ); + } + } + } + /** * Validates the options the plugin was constructed with. * @private @@ -1779,6 +1816,7 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateGenerators(); return; } @@ -1792,6 +1830,7 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateGenerators(); } /** diff --git a/src/options.json b/src/options.json index edfdb303..4f3672b7 100644 --- a/src/options.json +++ b/src/options.json @@ -202,7 +202,7 @@ ] }, "generate": { - "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed. An object names its generators, and an asset asks for one of them by name with `?as=`; a named generator written as an object can instead generate a new asset beside an emitted one.", + "description": "Rewrites a module's own bytes as it is built, which is what lets it be re-encoded into another format and renamed. Written as an object it states how to run one generator; written as an object of them it names its generators, and an asset asks for one by name with `?as=`.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generate", "anyOf": [ { @@ -215,6 +215,50 @@ "instanceof": "Function" } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The generator itself.", + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "array", + "minItems": 1, + "items": { + "instanceof": "Function" + } + } + ] + }, + "options": { + "description": "Options for this generator. Preferred over `generatorOptions`, which is deprecated; setting both for one generator is an error.", + "type": "object", + "additionalProperties": true + }, + "type": { + "description": "`import` re-encodes a module as it is built, so the import that asked for it is renamed with it. `asset` generates a new asset beside an emitted one, which nothing has to import.", + "enum": ["import", "asset"] + }, + "filename": { + "description": "Name for the generated asset, as a webpack filename template. `asset` generators only; by default the generator's own name for it is used.", + "type": "string", + "minLength": 1 + }, + "filter": { + "description": "Decides per asset whether to generate from it, on top of `test`/`include`/`exclude`.", + "instanceof": "Function" + }, + "deleteOriginalAssets": { + "description": "Removes the asset generated from. `asset` generators only.", + "type": "boolean" + } + }, + "required": ["implementation"] + }, { "type": "object", "minProperties": 1, @@ -249,6 +293,11 @@ } ] }, + "options": { + "description": "Options for this generator. Preferred over `generatorOptions`, which is deprecated; setting both for one generator is an error.", + "type": "object", + "additionalProperties": true + }, "type": { "description": "`import` re-encodes a module as it is built, so the import that asked for it is renamed with it. `asset` generates a new asset beside an emitted one, which nothing has to import.", "enum": ["import", "asset"] @@ -275,7 +324,7 @@ ] }, "generatorOptions": { - "description": "Options for the `generate` function. Keyed by preset name where `generate` names its generators.", + "description": "Deprecated alias for the `options` of a `generate` generator. Options for the `generate` function, keyed by name where `generate` names its generators.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generatoroptions", "anyOf": [ { diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 2deb7cef..a93a9222 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -834,6 +834,129 @@ describe("generate assets", () => { }); }); +describe("generate options", () => { + /** + * @param {string} tag bytes it prefixes its output with + * @returns {EXPECTED_ANY} an encoder that records the options it was handed + */ + function encoderNamed(tag) { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @param {undefined} sourceMap source map + * @param {{ tag?: string }} generatorOptions the options it was handed + * @returns {{ code: Buffer, filename: string }} the re-encoded result + */ + function encode(input, sourceMap, generatorOptions) { + const [[name, code]] = Object.entries(input); + + encode.saw = generatorOptions; + + return { + code: Buffer.concat([Buffer.from(`${tag}:`), Buffer.from(code)]), + filename: replaceExtension(name, "webp"), + }; + } + + encode.supportsBinary = () => true; + encode.supportsWorker = () => false; + encode.saw = undefined; + + return encode; + } + + /** + * @param {object} options plugin options + * @returns {Promise} what the build produced + */ + async function build(options) { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ test: /\.jpe?g$/i, ...options }).apply(compiler); + + return compile(compiler); + } + + it("should take a generator's options from `generate` itself", async () => { + const webp = encoderNamed("WEBP"); + const stats = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + options: { tag: "from-generate" }, + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(webp.saw.tag).toBe("from-generate"); + }); + + it("should still take them from the deprecated `generatorOptions`", async () => { + const webp = encoderNamed("WEBP"); + const stats = await build({ + generate: { webp: { implementation: webp, type: "asset" } }, + generatorOptions: { webp: { tag: "from-generator-options" } }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(webp.saw.tag).toBe("from-generator-options"); + }); + + it("should take them from an unnamed generator's own `options`", async () => { + const webp = encoderNamed("WEBP"); + const stats = await build({ + generate: { + implementation: webp, + type: "asset", + options: { tag: "unnamed" }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(webp.saw.tag).toBe("unnamed"); + }); + + /** + * Validation runs while webpack applies its plugins, so the plugin has to be + * in the config rather than applied to a compiler that already exists. + * @param {object} options plugin options + * @returns {import("webpack").Compiler} compiler + */ + function construct(options) { + return getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + plugins: [new MinimizerPlugin({ test: /\.jpe?g$/i, ...options })], + }); + } + + it("should reject options given in both places for one generator", () => { + const webp = encoderNamed("WEBP"); + + expect(() => + construct({ + generate: { webp: { implementation: webp, options: { tag: "a" } } }, + generatorOptions: { webp: { tag: "b" } }, + }), + ).toThrow(/'webp' generator in `generate` sets its own `options`/); + }); + + it("should reject a `generatorOptions` key naming no generator", () => { + const webp = encoderNamed("WEBP"); + + expect(() => + construct({ + generate: { webp: { implementation: webp, type: "asset" } }, + generatorOptions: { webp2: { tag: "typo" } }, + }), + ).toThrow(/`generatorOptions.webp2` names no generator/); + }); +}); + describe("generate option in watch mode", () => { let context; let watcher; diff --git a/types/index.d.ts b/types/index.d.ts index 65caebae..43b6330b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -89,12 +89,28 @@ declare class TerserPlugin { * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ private embeddedMinimizer; + /** + * One generator, however it was written: as the generator itself or as an + * object stating how to run it. + * @private + * @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 + */ + private describeGenerator; + /** + * Every generator `generate` holds, whichever shape it was written in. + * @private + * @returns {ReturnType[]} them, in the order they were written + */ + private generators; /** * The generator a module asks for by name, or the only one there is. * - * A `generate` written as an object is a set of named presets, and `?as=` - * picks between them: a module that names none is left alone, and one that - * names a preset nothing defines is an error rather than a silent decline. + * A `generate` naming its generators is picked between by `?as=`: a module + * that names none is left alone, and one that names a generator nothing + * defines is an error rather than a silent decline. * @private * @param {Compilation} compilation compilation * @param {string} resource the module's resource, query and all @@ -109,10 +125,10 @@ declare class TerserPlugin { */ private hasModuleGenerator; /** - * The named generators that run over emitted assets rather than over a - * module as it builds. + * The generators that run over emitted assets rather than over a module as + * it builds. * @private - * @returns {{ name: string, implementation: EXPECTED_ANY, options: EXPECTED_ANY, filename?: string, filter?: (name: string) => boolean, deleteOriginalAssets?: boolean }[]} them, in the order they were written + * @returns {ReturnType[]} them, in the order they were written */ private assetGenerators; /** @@ -176,6 +192,13 @@ declare class TerserPlugin { * @returns {Promise} the result, rewritten or as it came */ private generate; + /** + * Cross-field checks the schema cannot make: options given twice for one + * generator, and a `generatorOptions` key naming no generator. + * @private + * @returns {void} + */ + private validateGenerators; /** * Validates the options the plugin was constructed with. * @private From be712b16c2f9793dae8dc9f5b86491a3e76c28bf Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 18:35:26 +0000 Subject: [PATCH 05/13] docs: stop documenting `generatorOptions` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every example now gives a generator its options directly, and the option is covered by one note saying it still works — the same shape `terserOptions` already has under `minimizerOptions`. TODOs mark it for removal in the next major release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- README.md | 115 +++++++++++++++++------------------------------ src/index.js | 4 ++ src/options.json | 2 +- 3 files changed, 46 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 1238caaa..bf8032f5 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,6 @@ Using supported `devtool` values enable source map generation. - **[`minify`](#minify)** - **[`minimizerOptions`](#minimizeroptions)** - **[`generate`](#generate)** -- **[`generatorOptions`](#generatoroptions)** - **[`extractComments`](#extractcomments)** ### `test` @@ -610,8 +609,7 @@ bytes plus the generator and its options. Two generators ship with the plugin, and they differ in who picks the format. `sharpGenerate` is told: it takes the target from the request's `?as=` or, -failing that, from a [`generatorOptions.encodeOptions`](#generatoroptions) -naming exactly one format, and reports an error when neither says which format +failing that, from an `options.encodeOptions` naming exactly one format, and reports an error when neither says which format to write. `imageminGenerate` is not: its plugins decide, so it reads the format back off the bytes they produced and renames to match, leaving an asset its plugins did not convert under the name it had. @@ -652,13 +650,14 @@ one by name with `?as=`: new MinimizerPlugin({ test: /\.(jpe?g|png)$/i, generate: { - webp: MinimizerPlugin.sharpGenerate, - avif: MinimizerPlugin.sharpGenerate, - }, - // Keyed by preset name, rather than positionally, when `generate` is. - generatorOptions: { - webp: { encodeOptions: { webp: { quality: 90 } } }, - avif: { encodeOptions: { avif: { quality: 50 } } }, + webp: { + implementation: MinimizerPlugin.sharpGenerate, + options: { encodeOptions: { webp: { quality: 90 } } }, + }, + avif: { + implementation: MinimizerPlugin.sharpGenerate, + options: { encodeOptions: { avif: { quality: 50 } } }, + }, }, }); ``` @@ -682,8 +681,6 @@ new MinimizerPlugin({ generate: { webp: { implementation: MinimizerPlugin.sharpGenerate, - // Where this generator's options belong. `generatorOptions` still works - // but is deprecated, and setting both for one generator is an error. options: { encodeOptions: { webp: {} } }, type: "asset", // Optional. Without it the generator's own name for the result is used, @@ -709,6 +706,11 @@ interchangeable: `?as=` cannot reach it and it runs on any webpack. An asset already generated is never generated from again. +`ecma` is filled in from +[`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) +unless a generator's options set it, the same way it is for +[`minimizerOptions`](#minimizeroptions). + `filename` is a [webpack filename template](https://webpack.js.org/configuration/output/#outputfilename) resolved against the asset read, so `[path]`, `[name]`, `[base]`, `[ext]` and @@ -723,7 +725,7 @@ run it again, since its answer is cached under the bytes. The same holds across runs under [`cache.type: "filesystem"`](https://webpack.js.org/configuration/cache/#cachetype), where a module is restored from the pack rather than rebuilt. That restored -result is the generator's, so changing `generate` or `generatorOptions` has to +result is the generator's, so changing a generator or its options has to invalidate the pack, and the plugin adds their identity to [`cache.version`](https://webpack.js.org/configuration/cache/#cacheversion) so it does. This needs the plugin to be in the config — `plugins` or @@ -732,6 +734,16 @@ them; a plugin applied by hand after `webpack()` returns is too late to reach it, and a changed generator would then be ignored until the cache directory is removed. +> **Note** +> +> **Note** +> +> `generatorOptions` is kept as a deprecated way of giving a generator its +> options — one object for one generator, an array positionally matching an +> array of them, or keyed by name where `generate` names its generators. Prefer +> a generator's own `options`. Setting both for one generator is an error, and +> a key naming no generator is an error rather than silently doing nothing. + > **Note** > > An `"import"` generator needs a webpack whose `NormalModule` `processResult` @@ -740,51 +752,6 @@ removed. > generating nothing. An `"asset"` generator does not use that hook and works on > any supported webpack. -### `generatorOptions` - -Type: - -```ts -type generatorOptions = Record | Record[]; -``` - -Default: `{}` - -> **Note** -> -> `generatorOptions` is deprecated in favour of a generator's own `options`, -> which keeps one generator's configuration in one place — see -> [`generate`](#generate). It keeps working; setting both for the same -> generator is an error, and a key naming no generator is an error rather than -> silently doing nothing. - -Options for [`generate`](#generate), exactly as -[`minimizerOptions`](#minimizeroptions) is for [`minify`](#minify): one object -for one generator, or an array positionally matching an array of generators. A -single object handed an array of generators is reused for every one of them. -Where `generate` names its generators, this is keyed by the same names. - -`ecma` is filled in from -[`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) -unless the options set it, the same way it is for -[`minimizerOptions`](#minimizeroptions). - -```js -const MinimizerPlugin = require("minimizer-webpack-plugin"); - -module.exports = { - plugins: [ - new MinimizerPlugin({ - test: /\.(jpe?g|png)$/i, - generate: MinimizerPlugin.sharpGenerate, - // Names the format when the request does not, and carries the encoder's - // own settings either way. - generatorOptions: { encodeOptions: { webp: { quality: 90 } } }, - }), - ], -}; -``` - ### `extractComments` Type: @@ -2450,21 +2417,21 @@ This plugin does what did, for every asset type rather than images alone, so one plugin covers a build instead of two. The options line up like this: -| `image-minimizer-webpack-plugin` | here | -| ---------------------------------- | -------------------------------------------------------- | -| `minimizer.implementation` | [`minify`](#minify) | -| `minimizer.options` | [`minimizerOptions`](#minimizeroptions) | -| `minimizer.filter` | a `filter` on the minimizer itself | -| `generator[].implementation` | [`generate`](#generate) | -| `generator[].options` | [`generatorOptions`](#generatoroptions), keyed by preset | -| `generator[].preset` | the key the generator is written under | -| `generator[].type` | `type` on that generator | -| `generator[].filename` / `.filter` | `filename` / `filter` on that generator | -| `deleteOriginalAssets` | `deleteOriginalAssets` on that generator | -| `concurrency` | [`parallel`](#parallel) | -| `test` / `include` / `exclude` | unchanged | -| `loader` | nothing — `generate` reaches a module without one | -| `severityError` | nothing — a failed minimizer is an error | +| `image-minimizer-webpack-plugin` | here | +| ---------------------------------- | ------------------------------------------------- | +| `minimizer.implementation` | [`minify`](#minify) | +| `minimizer.options` | [`minimizerOptions`](#minimizeroptions) | +| `minimizer.filter` | a `filter` on the minimizer itself | +| `generator[].implementation` | [`generate`](#generate) | +| `generator[].options` | `options` on that generator | +| `generator[].preset` | the key the generator is written under | +| `generator[].type` | `type` on that generator | +| `generator[].filename` / `.filter` | `filename` / `filter` on that generator | +| `deleteOriginalAssets` | `deleteOriginalAssets` on that generator | +| `concurrency` | [`parallel`](#parallel) | +| `test` / `include` / `exclude` | unchanged | +| `loader` | nothing — `generate` reaches a module without one | +| `severityError` | nothing — a failed minimizer is an error | The minimizers and generators keep their names — `imageminMinify`, `imageminGenerate`, `imageminNormalizeConfig`, `sharpMinify`, @@ -2515,9 +2482,9 @@ module.exports = { webp: { type: "asset", implementation: MinimizerPlugin.sharpGenerate, + options: { encodeOptions: { webp: { quality: 90 } } }, }, }, - generatorOptions: { webp: { encodeOptions: { webp: { quality: 90 } } } }, }), ], }; diff --git a/src/index.js b/src/index.js index f575ef29..f0a2e074 100644 --- a/src/index.js +++ b/src/index.js @@ -1152,6 +1152,8 @@ class TerserPlugin { return { name, implementation: descriptor ? descriptor.implementation : entry, + // TODO remove the `generatorOptions` fallback in the next major release, + // where a generator's options are its own. options: (typeof own === "undefined" ? declared : own) || {}, type: descriptor ? descriptor.type : undefined, filename: descriptor ? descriptor.filename : undefined, @@ -1755,6 +1757,8 @@ class TerserPlugin { * @returns {void} */ validateGenerators() { + // TODO drop both checks in the next major release, with the deprecated + // `generatorOptions` they are about. const { generator } = this.options; if (!generator) { diff --git a/src/options.json b/src/options.json index 4f3672b7..bd46ebb6 100644 --- a/src/options.json +++ b/src/options.json @@ -324,7 +324,7 @@ ] }, "generatorOptions": { - "description": "Deprecated alias for the `options` of a `generate` generator. Options for the `generate` function, keyed by name where `generate` names its generators.", + "description": "Deprecated way of giving a `generate` generator its options, to be removed in the next major release. Prefer a generator's own `options`. Keyed by name where `generate` names its generators.", "link": "https://github.com/webpack/minimizer-webpack-plugin#generatoroptions", "anyOf": [ { From 0268d4f1e0292e22d6659821fc2d224b20c8088c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 21:48:28 +0000 Subject: [PATCH 06/13] feat: take a minimizer's options from `minify` itself A minimizer written as an object carries its own `options`, the way a generator now does, so one minimizer is configured in one place instead of being paired positionally with `minimizerOptions`. That option is deprecated but keeps working, and giving both for one minimizer is an error. Every example that paired the two is rewritten. The ones configuring the default minimizer are left as they are: `minimizerOptions` is still the only way to reach it without naming `terserMinify`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/minify-own-options.md | 5 + README.md | 509 +++++++++++------- src/index.js | 57 +- src/options.json | 53 +- src/utils.js | 50 +- .../validate-options.test.js.snap | 10 +- test/minify-option.test.js | 89 +++ types/index.d.ts | 7 + types/utils.d.ts | 25 +- 9 files changed, 575 insertions(+), 230 deletions(-) create mode 100644 .changeset/minify-own-options.md diff --git a/.changeset/minify-own-options.md b/.changeset/minify-own-options.md new file mode 100644 index 00000000..116584ce --- /dev/null +++ b/.changeset/minify-own-options.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Take a minimizer's options from `minify` itself, deprecating `minimizerOptions`; giving both for one minimizer is now an error. diff --git a/README.md b/README.md index bf8032f5..3c17b3bb 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,12 @@ type minifyFn = ( extractedComments?: string[] | undefined; }>; -type minify = minifyFn | minifyFn[]; +interface minimizer { + implementation: minifyFn | minifyFn[]; + options?: Record; +} + +type minify = minifyFn | (minifyFn | minimizer)[] | minimizer; ``` Default: `MinimizerPlugin.terserMinify` @@ -329,6 +334,18 @@ Allows you to override the default minify function. By default plugin uses [terser](https://github.com/terser/terser) package. Useful for using and testing unpublished versions or forks. +A minimizer can be written as an object instead, which is where its own +options live — one minimizer configured in one place: + +```js +new MinimizerPlugin({ + minify: { + implementation: MinimizerPlugin.swcMinify, + options: { mangle: false }, + }, +}); +``` + An array of functions can also be provided. Each minimizer can expose a `filter(name, info)` helper that decides whether it should run on a given asset; the plugin dispatches each asset only to the minimizers whose `filter` @@ -352,8 +369,9 @@ new MinimizerPlugin({ When more than one minimizer in the array claims the same asset, the chain semantic still applies: the output of each accepting minimizer is fed as -input to the next. The [`minimizerOptions`](#minimizeroptions) option may -be an array (index-paired with `minify`) or a single object reused by every +input to the next. A minimizer written as an object carries its own +`options`; the deprecated [`minimizerOptions`](#minimizeroptions) may still be +an array (index-paired with `minify`) or one object reused by every minimizer. The `test` option always defaults to `/\.[cm]?js(\?.*)?$/i`. When you mix @@ -429,10 +447,9 @@ module.exports = { If an array of functions is passed to the `minify` option, each asset is dispatched to the minimizers whose `filter` accepts it. When more than one minimizer accepts the same asset the output of each is fed as input to the -next one (the chain semantic). The `minimizerOptions` option can be either an -array of option objects (index-paired with `minify`) or a single object that -will be shared by all minimizers. Warnings, errors and extracted comments -from all running minimizers are merged together. +next one (the chain semantic). Each entry may be the minimizer itself or an +object carrying that minimizer's own `options`. Warnings, errors and extracted +comments from all running minimizers are merged together. **webpack.config.js** @@ -442,14 +459,19 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: [MinimizerPlugin.terserMinify, MinimizerPlugin.swcMinify], - // `minimizerOptions` can be an array of options, one per `minify` entry - minimizerOptions: [ - // Options for `MinimizerPlugin.terserMinify` - { mangle: false }, - // Options for `MinimizerPlugin.swcMinify` - {}, - ], + minify: { + implementation: [ + MinimizerPlugin.terserMinify, + MinimizerPlugin.swcMinify, + ], + // One entry per implementation, in the same order + options: [ + // Options for `MinimizerPlugin.terserMinify` + { mangle: false }, + // Options for `MinimizerPlugin.swcMinify` + {}, + ], + }, }), ], }, @@ -510,13 +532,22 @@ type options = minimizerOptions | minimizerOptions[]; Default: [default](https://github.com/terser/terser#minify-options) -Options for the active minimizer. With the default Terser minify, see Terser's +> **Note** +> +> `minimizerOptions` is deprecated in favour of a minimizer's own `options`, +> which keeps one minimizer's configuration in one place — see +> [`minify`](#minify). It keeps working, and setting both for the same +> minimizer is an error. It is still the way to configure the **default** +> minimizer without naming it. + +Options for the active minimizer, whichever of the two places they are given +in. With the default Terser minify, see Terser's [minify options](https://github.com/terser/terser#minify-options). -When the [`minify`](#minify) option is an array of minimizers, `minimizerOptions` -can also be an array. Each element is passed to the minimizer at the same -index in the `minify` array. If a single object is provided instead, it is -reused for every minimizer. +When the [`minify`](#minify) option is an array of minimizers, +`minimizerOptions` can also be an array. Each element is passed to the +minimizer at the same index in the `minify` array. If a single object is +provided instead, it is reused for every minimizer. Two keys are filled in before a minimizer sees them, and only when the options do not already set them: `ecma`, from @@ -1164,10 +1195,12 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: MinimizerPlugin.uglifyJsMinify, - // `minimizerOptions` will be passed to `uglify-js` - // Link to options - https://github.com/mishoo/UglifyJS#minify-options - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.uglifyJsMinify, + // `options` will be passed to `uglify-js` + // Link to options - https://github.com/mishoo/UglifyJS#minify-options + options: {}, + }, }), ], }, @@ -1192,10 +1225,12 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: MinimizerPlugin.swcMinify, - // `minimizerOptions` will be passed to `swc` (`@swc/core`) - // Link to options - https://swc.rs/docs/config-js-minify - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.swcMinify, + // `options` will be passed to `swc` (`@swc/core`) + // Link to options - https://swc.rs/docs/config-js-minify + options: {}, + }, }), ], }, @@ -1218,17 +1253,19 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: MinimizerPlugin.esbuildMinify, - // `minimizerOptions` will be passed to `esbuild` - // Link to options - https://esbuild.github.io/api/#minify - // Note: the `minify` options is true by default (and override other `minify*` options), so if you want to disable the `minifyIdentifiers` option (or other `minify*` options) please use: - // minimizerOptions: { - // minify: false, - // minifyWhitespace: true, - // minifyIdentifiers: false, - // minifySyntax: true, - // }, - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.esbuildMinify, + // `options` will be passed to `esbuild` + // Link to options - https://esbuild.github.io/api/#minify + // Note: the `minify` options is true by default (and override other `minify*` options), so if you want to disable the `minifyIdentifiers` option (or other `minify*` options) please use: + // options: { + // minify: false, + // minifyWhitespace: true, + // minifyIdentifiers: false, + // minifySyntax: true, + // }, + options: {}, + }, }), ], }, @@ -1251,9 +1288,11 @@ module.exports = { // Will minify JSON files (they can come from copy-webpack-plugin or when you are using asset modules) new MinimizerPlugin({ test: /\.json$/, - minify: MinimizerPlugin.jsonMinify, - // We are supporting `space` and `replacer` options, you can set them below - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.jsonMinify, + // We are supporting `space` and `replacer` options, you can set them below + options: {}, + }, }), ], }, @@ -1316,11 +1355,13 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.html(\?.*)?$/i, - minify: MinimizerPlugin.htmlMinifierTerser, - // Options - https://github.com/terser/html-minifier-terser#options-quick-reference - minimizerOptions: { - collapseWhitespace: true, - removeComments: true, + minify: { + implementation: MinimizerPlugin.htmlMinifierTerser, + // Options - https://github.com/terser/html-minifier-terser#options-quick-reference + options: { + collapseWhitespace: true, + removeComments: true, + }, }, }), ], @@ -1344,9 +1385,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.html(\?.*)?$/i, - minify: MinimizerPlugin.swcMinifyHtml, - // Options - https://github.com/swc-project/bindings/blob/main/packages/html/index.ts - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.swcMinifyHtml, + // Options - https://github.com/swc-project/bindings/blob/main/packages/html/index.ts + options: {}, + }, }), ], }, @@ -1369,9 +1412,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.template\.html$/i, - minify: MinimizerPlugin.swcMinifyHtmlFragment, - // Options - https://github.com/swc-project/bindings/blob/main/packages/html/index.ts - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.swcMinifyHtmlFragment, + // Options - https://github.com/swc-project/bindings/blob/main/packages/html/index.ts + options: {}, + }, }), ], }, @@ -1399,9 +1444,11 @@ module.exports = { "...", new Minimizer({ test: /\.html(\?.*)?$/i, - minify: Minimizer.minifyHtmlNode, - // Options - https://github.com/wilsonzlin/minify-html#minification - minimizerOptions: {}, + minify: { + implementation: Minimizer.minifyHtmlNode, + // Options - https://github.com/wilsonzlin/minify-html#minification + options: {}, + }, }), ], }, @@ -1465,10 +1512,12 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cssnanoMinify, - // Options - https://cssnano.github.io/cssnano/docs/config-file/ - minimizerOptions: { - preset: "default", + minify: { + implementation: MinimizerPlugin.cssnanoMinify, + // Options - https://cssnano.github.io/cssnano/docs/config-file/ + options: { + preset: "default", + }, }, }), ], @@ -1492,9 +1541,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cssoMinify, - // Options - https://github.com/css/csso#minifysource-options - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.cssoMinify, + // Options - https://github.com/css/csso#minifysource-options + options: {}, + }, }), ], }, @@ -1517,9 +1568,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cleanCssMinify, - // Options - https://github.com/clean-css/clean-css#constructor-options - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.cleanCssMinify, + // Options - https://github.com/clean-css/clean-css#constructor-options + options: {}, + }, }), ], }, @@ -1542,9 +1595,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.esbuildMinifyCss, - // Options - https://esbuild.github.io/api/#transform-api - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.esbuildMinifyCss, + // Options - https://esbuild.github.io/api/#transform-api + options: {}, + }, }), ], }, @@ -1567,9 +1622,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.lightningCssMinify, - // Options - https://lightningcss.dev/transpilation.html - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.lightningCssMinify, + // Options - https://lightningcss.dev/transpilation.html + options: {}, + }, }), ], }, @@ -1592,9 +1649,11 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.swcMinifyCss, - // Options - https://github.com/swc-project/bindings/blob/main/packages/css/index.ts - minimizerOptions: {}, + minify: { + implementation: MinimizerPlugin.swcMinifyCss, + // Options - https://github.com/swc-project/bindings/blob/main/packages/css/index.ts + options: {}, + }, }), ], }, @@ -1622,14 +1681,16 @@ module.exports = { minimizer: [ new MinimizerPlugin({ test: /\.(?:[cm]?js|css|html|json)(\?.*)?$/i, - minify: [ - MinimizerPlugin.terserMinify, - cssMinify, - htmlMinify, - MinimizerPlugin.jsonMinify, - ], - // Positional: one entry per `minify` entry, in the same order. - minimizerOptions: [{}, {}, {}, {}], + minify: { + implementation: [ + MinimizerPlugin.terserMinify, + cssMinify, + htmlMinify, + MinimizerPlugin.jsonMinify, + ], + // Positional: one entry per `minify` entry, in the same order. + options: [{}, {}, {}, {}], + }, }), ], }, @@ -1845,15 +1906,17 @@ off. ```js new MinimizerPlugin({ test: /\.(png|jpe?g|webp|avif)(\?.*)?$/i, - minify: MinimizerPlugin.sharpMinify, - minimizerOptions: { - encodeOptions: { - // https://sharp.pixelplumbing.com/api-output - jpeg: { quality: 100 }, - webp: { lossless: true }, - avif: { lossless: true }, - // PNG is already lossless at sharp's defaults - png: {}, + minify: { + implementation: MinimizerPlugin.sharpMinify, + options: { + encodeOptions: { + // https://sharp.pixelplumbing.com/api-output + jpeg: { quality: 100 }, + webp: { lossless: true }, + avif: { lossless: true }, + // PNG is already lossless at sharp's defaults + png: {}, + }, }, }, }); @@ -1864,14 +1927,16 @@ new MinimizerPlugin({ ```js new MinimizerPlugin({ test: /\.(png|jpe?g|webp|avif)(\?.*)?$/i, - minify: MinimizerPlugin.napiRsImageMinify, - minimizerOptions: { - encodeOptions: { - // Anything below 100 re-encodes rather than repacking - jpeg: { quality: 80 }, - webp: { quality: 80 }, - avif: { quality: 70 }, - // `png` has no quality setting — it is lossless either way + minify: { + implementation: MinimizerPlugin.napiRsImageMinify, + options: { + encodeOptions: { + // Anything below 100 re-encodes rather than repacking + jpeg: { quality: 80 }, + webp: { quality: 80 }, + avif: { quality: 70 }, + // `png` has no quality setting — it is lossless either way + }, }, }, }); @@ -1902,14 +1967,16 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.(png|jpe?g|webp|avif|tiff?|gif)$/i, - minify: MinimizerPlugin.sharpMinify, - minimizerOptions: { - // Options are keyed by sharp's format name - // https://sharp.pixelplumbing.com/api-output - encodeOptions: { - jpeg: { quality: 80 }, - png: { compressionLevel: 9 }, - webp: { lossless: true }, + minify: { + implementation: MinimizerPlugin.sharpMinify, + options: { + // Options are keyed by sharp's format name + // https://sharp.pixelplumbing.com/api-output + encodeOptions: { + jpeg: { quality: 80 }, + png: { compressionLevel: 9 }, + webp: { lossless: true }, + }, }, }, }), @@ -1923,21 +1990,23 @@ module.exports = { ```js new MinimizerPlugin({ test: /\.(png|jpe?g)$/i, - minify: MinimizerPlugin.sharpMinify, - minimizerOptions: { - // `enabled` and `unit` ("px" by default, or "percent") are read here; - // everything else goes to sharp - // https://sharp.pixelplumbing.com/api-resize - resize: { width: 800, unit: "px", fit: "inside" }, - // A number of degrees, or "auto" to follow the EXIF orientation - rotate: "auto", - flip: false, - flop: false, - grayscale: false, - // A sigma, or true for a fast default - blur: false, - sharpen: false, - encodeOptions: { jpeg: { quality: 80 } }, + minify: { + implementation: MinimizerPlugin.sharpMinify, + options: { + // `enabled` and `unit` ("px" by default, or "percent") are read here; + // everything else goes to sharp + // https://sharp.pixelplumbing.com/api-resize + resize: { width: 800, unit: "px", fit: "inside" }, + // A number of degrees, or "auto" to follow the EXIF orientation + rotate: "auto", + flip: false, + flop: false, + grayscale: false, + // A sigma, or true for a fast default + blur: false, + sharpen: false, + encodeOptions: { jpeg: { quality: 80 } }, + }, }, }); ``` @@ -1964,12 +2033,14 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.svg(\?.*)?$/i, - minify: MinimizerPlugin.svgoMinify, - minimizerOptions: { - // Options - https://github.com/svg/svgo#configuration - encodeOptions: { - multipass: true, - plugins: ["preset-default"], + minify: { + implementation: MinimizerPlugin.svgoMinify, + options: { + // Options - https://github.com/svg/svgo#configuration + encodeOptions: { + multipass: true, + plugins: ["preset-default"], + }, }, }, }), @@ -1996,14 +2067,16 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.(png|jpe?g|gif|svg)$/i, - minify: MinimizerPlugin.imageminMinify, - minimizerOptions: { - plugins: [ - "gifsicle", - "mozjpeg", - ["pngquant", { quality: [0.6, 0.8] }], - "svgo", - ], + minify: { + implementation: MinimizerPlugin.imageminMinify, + options: { + plugins: [ + "gifsicle", + "mozjpeg", + ["pngquant", { quality: [0.6, 0.8] }], + "svgo", + ], + }, }, }), ], @@ -2045,19 +2118,21 @@ module.exports = { "...", new MinimizerPlugin({ test: /\.(png|jpe?g|webp|avif)$/i, - minify: MinimizerPlugin.napiRsImageMinify, - minimizerOptions: { - // Options are keyed by the format's own name - // https://github.com/Brooooooklyn/Image#usage - encodeOptions: { - // `PNGLosslessOptions` - png: { force: true }, - // `JpegCompressOptions` - jpeg: { quality: 80 }, - // `AvifConfig` - avif: { quality: 70, speed: 4 }, - // The quality factor, 0-100 - webp: { quality: 80 }, + minify: { + implementation: MinimizerPlugin.napiRsImageMinify, + options: { + // Options are keyed by the format's own name + // https://github.com/Brooooooklyn/Image#usage + encodeOptions: { + // `PNGLosslessOptions` + png: { force: true }, + // `JpegCompressOptions` + jpeg: { quality: 80 }, + // `AvifConfig` + avif: { quality: 70, speed: 4 }, + // The quality factor, 0-100 + webp: { quality: 80 }, + }, }, }, }), @@ -2222,19 +2297,21 @@ module.exports = { minimizer: [ new MinimizerPlugin({ test: /\.(js|css|svg|png|jpe?g)$/i, - minify: [ - MinimizerPlugin.terserMinify, - MinimizerPlugin.cssnanoMinify, - MinimizerPlugin.svgoMinify, - MinimizerPlugin.sharpMinify, - ], - // One entry per minimizer, in the same order - minimizerOptions: [ - {}, - {}, - {}, - { encodeOptions: { png: { compressionLevel: 9 } } }, - ], + minify: { + implementation: [ + MinimizerPlugin.terserMinify, + MinimizerPlugin.cssnanoMinify, + MinimizerPlugin.svgoMinify, + MinimizerPlugin.sharpMinify, + ], + // One entry per minimizer, in the same order + options: [ + {}, + {}, + {}, + { encodeOptions: { png: { compressionLevel: 9 } } }, + ], + }, }), ], }, @@ -2313,96 +2390,122 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: MinimizerPlugin.swcMinify, - minimizerOptions: { - // `swc` options + minify: { + implementation: MinimizerPlugin.swcMinify, + options: { + // `swc` options + }, }, }), new MinimizerPlugin({ - minify: MinimizerPlugin.uglifyJsMinify, - minimizerOptions: { - // `uglif-js` options + minify: { + implementation: MinimizerPlugin.uglifyJsMinify, + options: { + // `uglif-js` options + }, }, }), new MinimizerPlugin({ - minify: MinimizerPlugin.esbuildMinify, - minimizerOptions: { - // `esbuild` options + minify: { + implementation: MinimizerPlugin.esbuildMinify, + options: { + // `esbuild` options + }, }, }), // Alternative usage: new MinimizerPlugin({ - minify: MinimizerPlugin.terserMinify, - minimizerOptions: { - // `terser` options + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { + // `terser` options + }, }, }), // HTML minimizers new MinimizerPlugin({ test: /\.html(\?.*)?$/i, - minify: MinimizerPlugin.htmlMinifierTerser, - minimizerOptions: { - // `html-minifier-terser` options + minify: { + implementation: MinimizerPlugin.htmlMinifierTerser, + options: { + // `html-minifier-terser` options + }, }, }), new MinimizerPlugin({ test: /\.html(\?.*)?$/i, - minify: MinimizerPlugin.swcMinifyHtml, - minimizerOptions: { - // `@swc/html` options + minify: { + implementation: MinimizerPlugin.swcMinifyHtml, + options: { + // `@swc/html` options + }, }, }), new MinimizerPlugin({ test: /\.template\.html$/i, - minify: MinimizerPlugin.swcMinifyHtmlFragment, - minimizerOptions: { - // `@swc/html` fragment options + minify: { + implementation: MinimizerPlugin.swcMinifyHtmlFragment, + options: { + // `@swc/html` fragment options + }, }, }), // CSS minimizers new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cssnanoMinify, - minimizerOptions: { - // `cssnano` options + minify: { + implementation: MinimizerPlugin.cssnanoMinify, + options: { + // `cssnano` options + }, }, }), new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cssoMinify, - minimizerOptions: { - // `csso` options + minify: { + implementation: MinimizerPlugin.cssoMinify, + options: { + // `csso` options + }, }, }), new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.cleanCssMinify, - minimizerOptions: { - // `clean-css` options + minify: { + implementation: MinimizerPlugin.cleanCssMinify, + options: { + // `clean-css` options + }, }, }), new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.esbuildMinifyCss, - minimizerOptions: { - // `esbuild` options (CSS loader) + minify: { + implementation: MinimizerPlugin.esbuildMinifyCss, + options: { + // `esbuild` options (CSS loader) + }, }, }), new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.lightningCssMinify, - minimizerOptions: { - // `lightningcss` options + minify: { + implementation: MinimizerPlugin.lightningCssMinify, + options: { + // `lightningcss` options + }, }, }), new MinimizerPlugin({ test: /\.css(\?.*)?$/i, - minify: MinimizerPlugin.swcMinifyCss, - minimizerOptions: { - // `@swc/css` options + minify: { + implementation: MinimizerPlugin.swcMinifyCss, + options: { + // `@swc/css` options + }, }, }), ], @@ -2420,7 +2523,7 @@ build instead of two. The options line up like this: | `image-minimizer-webpack-plugin` | here | | ---------------------------------- | ------------------------------------------------- | | `minimizer.implementation` | [`minify`](#minify) | -| `minimizer.options` | [`minimizerOptions`](#minimizeroptions) | +| `minimizer.options` | `options` on that minimizer | | `minimizer.filter` | a `filter` on the minimizer itself | | `generator[].implementation` | [`generate`](#generate) | | `generator[].options` | `options` on that generator | diff --git a/src/index.js b/src/index.js index f0a2e074..54c3b88e 100644 --- a/src/index.js +++ b/src/index.js @@ -16,13 +16,14 @@ const { imageminMinify, imageminNormalizeConfig, interpolateSize, - isGeneratorDescriptor, + isDescriptor, isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, + normalizeMinimizers, readPreset, sharpGenerate, sharpMinify, @@ -285,10 +286,9 @@ class TerserPlugin { parallel, include, exclude, - minimizer: { - implementation: minify, - options: resolvedMinimizerOptions, - }, + minimizer: + /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions }} */ + (normalizeMinimizers(minify, resolvedMinimizerOptions)), // Absent unless asked for: it runs while modules build, where the plugin // otherwise does nothing. generator: generate @@ -1146,7 +1146,7 @@ class TerserPlugin { * @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 */ describeGenerator(name, entry, declared) { - const descriptor = isGeneratorDescriptor(entry) ? entry : undefined; + const descriptor = isDescriptor(entry) ? entry : undefined; const own = descriptor ? descriptor.options : undefined; return { @@ -1179,7 +1179,7 @@ class TerserPlugin { const written = generator.implementation; const declared = generator.options; - if (!isPresets(written) || isGeneratorDescriptor(written)) { + if (!isPresets(written) || isDescriptor(written)) { return [this.describeGenerator(undefined, written, declared)]; } @@ -1750,6 +1750,43 @@ class TerserPlugin { return [output.code, result[1], result[2]]; } + /** + * The same check as `validateGenerators`, for a minimizer: its options + * cannot come from `minify` and the deprecated `minimizerOptions` both. + * @private + * @returns {void} + */ + validateMinimizers() { + // TODO drop this check in the next major release, with the deprecated + // `minimizerOptions` it is about. + const { minify, minimizerOptions, terserOptions } = this.rawOptions; + const declared = + typeof minimizerOptions === "undefined" + ? terserOptions + : minimizerOptions; + + if (typeof declared === "undefined") { + return; + } + + const written = Array.isArray(minify) ? minify : [minify]; + + for (const [index, one] of written.entries()) { + const own = isDescriptor(one) ? one.options : undefined; + const twice = Array.isArray(minify) + ? getMinimizerOptionsAt(declared, index) + : declared; + + if (typeof own !== "undefined" && typeof twice !== "undefined") { + throw new Error( + Array.isArray(minify) + ? `The minimizer at \`minify[${index}]\` sets its own \`options\`, and the deprecated \`minimizerOptions\` sets them too. Keep the one in \`minify\`.` + : "`minify` sets its own `options`, and the deprecated `minimizerOptions` sets them too. Keep the one in `minify`.", + ); + } + } + } + /** * Cross-field checks the schema cannot make: options given twice for one * generator, and a `generatorOptions` key naming no generator. @@ -1767,7 +1804,7 @@ class TerserPlugin { const written = generator.implementation; const declared = generator.options; - const named = isPresets(written) && !isGeneratorDescriptor(written); + const named = isPresets(written) && !isDescriptor(written); const presets = /** @type {{ [preset: string]: EXPECTED_ANY }} */ (/** @type {unknown} */ (written)); @@ -1777,7 +1814,7 @@ class TerserPlugin { for (const name of named ? Object.keys(presets) : [undefined]) { const entry = typeof name === "undefined" ? written : presets[name]; - const own = isGeneratorDescriptor(entry) ? entry.options : undefined; + const own = isDescriptor(entry) ? entry.options : undefined; const twice = typeof name === "undefined" ? declared : perPreset && perPreset[name]; @@ -1820,6 +1857,7 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateMinimizers(); this.validateGenerators(); return; @@ -1834,6 +1872,7 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateMinimizers(); this.validateGenerators(); } diff --git a/src/options.json b/src/options.json index bd46ebb6..836c5845 100644 --- a/src/options.json +++ b/src/options.json @@ -65,7 +65,7 @@ ] }, "minimizerOptions": { - "description": "Options for `terser` (by default) or custom `minify` function.", + "description": "Deprecated way of giving a `minify` minimizer its options, to be removed in the next major release. Prefer a minimizer's own `options`.", "link": "https://github.com/webpack/minimizer-webpack-plugin#minimizeroptions", "anyOf": [ { @@ -186,7 +186,7 @@ ] }, "minify": { - "description": "Allows you to override default minify function.", + "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": [ { @@ -196,8 +196,55 @@ "type": "array", "minItems": 1, "items": { - "instanceof": "Function" + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The minimizer itself.", + "instanceof": "Function" + }, + "options": { + "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", + "type": "object", + "additionalProperties": true + } + }, + "required": ["implementation"] + } + ] } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The minimizer itself.", + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "array", + "minItems": 1, + "items": { + "instanceof": "Function" + } + } + ] + }, + "options": { + "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", + "type": "object", + "additionalProperties": true + } + }, + "required": ["implementation"] } ] }, diff --git a/src/utils.js b/src/utils.js index f6a1d853..f61fce11 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2260,12 +2260,12 @@ function interpolateSize(filename, size) { } /** - * Whether one named generator was written as an object stating how to run it, - * rather than as the generator itself. - * @param {EXPECTED_ANY} entry one entry of a `generate` preset object - * @returns {boolean} true when it describes a generator + * Whether a minimizer or generator was written as an object stating how to run + * it, rather than as the function itself. + * @param {EXPECTED_ANY} entry what `minify` or `generate` holds + * @returns {boolean} true when it describes one */ -function isGeneratorDescriptor(entry) { +function isDescriptor(entry) { return ( typeof entry === "object" && entry !== null && @@ -2274,6 +2274,43 @@ function isGeneratorDescriptor(entry) { ); } +/** + * 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. + * @param {EXPECTED_ANY} minify what `minify` was set to + * @param {EXPECTED_ANY} declared what `minimizerOptions` says + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY }} the pair + */ +function normalizeMinimizers(minify, declared) { + if (Array.isArray(minify)) { + if (!minify.some(isDescriptor)) { + return { implementation: minify, options: declared }; + } + + return { + implementation: minify.map((one) => + isDescriptor(one) ? one.implementation : one, + ), + options: minify.map((one, index) => + isDescriptor(one) && typeof one.options !== "undefined" + ? one.options + : getMinimizerOptionsAt(declared, index), + ), + }; + } + + if (isDescriptor(minify)) { + return { + implementation: minify.implementation, + options: + typeof minify.options === "undefined" ? declared : minify.options, + }; + } + + return { implementation: minify, options: declared }; +} + /** * The preset an asset's own name asks for, as `?as=webp`. * @param {string} name asset name, query and all @@ -3474,13 +3511,14 @@ module.exports = { imageminMinify, imageminNormalizeConfig, interpolateSize, - isGeneratorDescriptor, + isDescriptor, isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, + normalizeMinimizers, packageVersion, readPreset, replaceExtension, diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index bca22f65..3c9f4c73 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,13 +132,15 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Terser Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function, ...] (should not have fewer than 1 item) - -> Allows you to override default minify function. + function | [function | object { implementation, options? }, ...] (should not have fewer than 1 item) | object { implementation, options? } + -> 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, ...] (should not have fewer than 1 item)" + [function | object { implementation, options? }, ...] (should not have fewer than 1 item) + * options.minify should be an object: + object { implementation, options? }" `; exports[`validation validate 11`] = ` @@ -231,7 +233,7 @@ exports[`validation validate 19`] = ` "Invalid options object. Terser Plugin has been initialized using an options object that does not match the API schema. - options.minimizerOptions should be one of these: object { … } | [object { … }, ...] (should not have fewer than 1 item) - -> Options for \`terser\` (by default) or custom \`minify\` function. + -> Deprecated way of giving a \`minify\` minimizer its options, to be removed in the next major release. Prefer a minimizer's own \`options\`. -> Read more at https://github.com/webpack/minimizer-webpack-plugin#minimizeroptions Details: * options.minimizerOptions should be an object: diff --git a/test/minify-option.test.js b/test/minify-option.test.js index a68e2068..a562327e 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1621,3 +1621,92 @@ describe("getMinimizerVersion", () => { }, ); }); + +describe("minify option written as an object", () => { + /** + * @returns {EXPECTED_ANY} a minimizer that records the options it was handed + */ + function recorder() { + /** + * @param {{ [file: string]: string }} input input + * @param {undefined} sourceMap source map + * @param {{ tag?: string }} minimizerOptions the options it was handed + * @returns {{ code: string }} the minified result + */ + function minimize(input, sourceMap, minimizerOptions) { + const [[, code]] = Object.entries(input); + + minimize.saw = minimizerOptions; + + return { code }; + } + + minimize.supportsWorker = () => false; + minimize.saw = undefined; + + return minimize; + } + + it("should take a minimizer's options from `minify` itself", async () => { + const first = recorder(); + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: { implementation: first, options: { tag: "from-minify" } }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(first.saw.tag).toBe("from-minify"); + }); + + it("should give each minimizer in an array its own options", async () => { + const first = recorder(); + const second = recorder(); + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: [ + { implementation: first, options: { tag: "first" } }, + { implementation: second, options: { tag: "second" } }, + ], + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(first.saw.tag).toBe("first"); + expect(second.saw.tag).toBe("second"); + }); + + it("should still take them from the deprecated `minimizerOptions`", async () => { + const first = recorder(); + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: { implementation: first }, + minimizerOptions: { tag: "from-minimizer-options" }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(first.saw.tag).toBe("from-minimizer-options"); + }); + + it("should reject options given in both places for one minimizer", () => { + const first = recorder(); + + expect(() => + getCompiler({ + plugins: [ + new MinimizerPlugin({ + minify: { implementation: first, options: { tag: "a" } }, + minimizerOptions: { tag: "b" }, + }), + ], + }), + ).toThrow(/`minify` sets its own `options`/); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 43b6330b..679938a8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -192,6 +192,13 @@ declare class TerserPlugin { * @returns {Promise} the result, rewritten or as it came */ private generate; + /** + * The same check as `validateGenerators`, for a minimizer: its options + * cannot come from `minify` and the deprecated `minimizerOptions` both. + * @private + * @returns {void} + */ + private validateMinimizers; /** * Cross-field checks the schema cannot make: options given twice for one * generator, and a `generatorOptions` key naming no generator. diff --git a/types/utils.d.ts b/types/utils.d.ts index d5c2fdb2..08d31a12 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -352,12 +352,12 @@ export function interpolateSize( }, ): string; /** - * Whether one named generator was written as an object stating how to run it, - * rather than as the generator itself. - * @param {EXPECTED_ANY} entry one entry of a `generate` preset object - * @returns {boolean} true when it describes a generator + * Whether a minimizer or generator was written as an object stating how to run + * it, rather than as the function itself. + * @param {EXPECTED_ANY} entry what `minify` or `generate` holds + * @returns {boolean} true when it describes one */ -export function isGeneratorDescriptor(entry: EXPECTED_ANY): boolean; +export function isDescriptor(entry: EXPECTED_ANY): boolean; /** * Whether `generate` was written as a set of named presets rather than as one * generator or a pipeline of them. A function is never a set; an array is a @@ -508,6 +508,21 @@ export namespace napiRsImageMinify { */ function filter(name: string): boolean; } +/** + * 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. + * @param {EXPECTED_ANY} minify what `minify` was set to + * @param {EXPECTED_ANY} declared what `minimizerOptions` says + * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY }} the pair + */ +export function normalizeMinimizers( + minify: EXPECTED_ANY, + declared: EXPECTED_ANY, +): { + implementation: EXPECTED_ANY; + options: EXPECTED_ANY; +}; /** * The version a package reports. Read by walking up from its resolved entry * point rather than by requiring `/package.json`, which a package whose From 6b9ecb30cd672845242c4a68f86765b3a401e5e6 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 21:55:12 +0000 Subject: [PATCH 07/13] test: cover preset resolution directly `readPreset` and `generatorFor` decide which generator an asset reaches, and no build on a released webpack can run them: `generate` in module mode needs a `processResult` hook that can await, so the integration cases skip themselves. Driving both directly covers the branches that skip leaves untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- test/generate-option.test.js | 84 +++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/test/generate-option.test.js b/test/generate-option.test.js index a93a9222..8488eb50 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import MinimizerPlugin from "../src"; -import { replaceExtension } from "../src/utils"; +import { readPreset, replaceExtension } from "../src/utils"; import { compile, @@ -629,6 +629,88 @@ describe("generate presets", () => { }); }); +describe("preset resolution", () => { + // `generate` in module mode needs a webpack that can await `processResult`, + // so these branches are unreachable from a build on any released one. + it("should read the preset an asset's own name asks for", () => { + expect(readPreset("image.jpg?as=webp")).toBe("webp"); + expect(readPreset("image.jpg?as=webp#fragment")).toBe("webp"); + expect(readPreset("image.jpg?width=100&as=avif")).toBe("avif"); + expect(readPreset("image.jpg")).toBeUndefined(); + expect(readPreset("image.jpg?width=100")).toBeUndefined(); + expect(readPreset("image.jpg?as=")).toBeUndefined(); + expect(readPreset("image.jpg#as=webp")).toBeUndefined(); + }); + + /** + * @returns {EXPECTED_ANY} something `generatorFor` can push errors onto + */ + const stubCompilation = () => ({ errors: [] }); + + it("should return the only generator when none are named", () => { + const plugin = new MinimizerPlugin({ generate: toWebp }); + const compilation = stubCompilation(); + + expect(plugin.generatorFor(compilation, "image.jpg").implementation).toBe( + toWebp, + ); + expect(compilation.errors).toEqual([]); + }); + + it("should return the named generator an asset asks for", () => { + const plugin = new MinimizerPlugin({ generate: { webp: toWebp } }); + const compilation = stubCompilation(); + + expect( + plugin.generatorFor(compilation, "image.jpg?as=webp").implementation, + ).toBe(toWebp); + expect(compilation.errors).toEqual([]); + }); + + it("should leave an asset naming no generator alone", () => { + const plugin = new MinimizerPlugin({ generate: { webp: toWebp } }); + const compilation = stubCompilation(); + + expect(plugin.generatorFor(compilation, "image.jpg")).toBeUndefined(); + expect(compilation.errors).toEqual([]); + }); + + it("should report an asset naming a generator nothing defines", () => { + const plugin = new MinimizerPlugin({ generate: { webp: toWebp } }); + const compilation = stubCompilation(); + + expect( + plugin.generatorFor(compilation, "image.jpg?as=jxl"), + ).toBeUndefined(); + expect(compilation.errors).toHaveLength(1); + expect(compilation.errors[0].message).toMatch( + /no 'jxl' preset in `generate`, which defines 'webp'/, + ); + }); + + it("should not reach an `asset` generator through `?as=`", () => { + const plugin = new MinimizerPlugin({ + generate: { webp: { implementation: toWebp, type: "asset" } }, + }); + const compilation = stubCompilation(); + + expect( + plugin.generatorFor(compilation, "image.jpg?as=webp"), + ).toBeUndefined(); + expect(compilation.errors).toEqual([]); + }); + + it("should hand a named generator its own options", () => { + const plugin = new MinimizerPlugin({ + generate: { webp: { implementation: toWebp, options: { tag: "own" } } }, + }); + + expect( + plugin.generatorFor(stubCompilation(), "image.jpg?as=webp").options, + ).toEqual({ tag: "own" }); + }); +}); + describe("generate assets", () => { /** * An encoder that reports how often it ran, so a test can tell "declined" from From 968358883090aef585d8638cc3baef08ba8bacd2 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 22:00:20 +0000 Subject: [PATCH 08/13] test: cover what an asset generator reports, and a salt built from several An `asset` generator that throws, one that returns errors and warnings, and one whose name resolves to an asset already emitted each take a branch nothing drove. Two named generators under the filesystem cache cover a salt built from more than one, which `asset` generators can exercise on any webpack. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- test/generate-option.test.js | 98 ++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 8488eb50..39c71719 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -849,6 +849,77 @@ describe("generate assets", () => { expect(assets).not.toContain("image.jpg"); }); + it("should report an asset generator that throws", async () => { + /** + * @returns {never} never returns + */ + function boom() { + throw new Error("the encoder gave up"); + } + + boom.supportsBinary = () => true; + boom.supportsWorker = () => false; + + const { stats, assets } = await build({ + generate: { webp: { implementation: boom, type: "asset" } }, + }); + + expect(getErrors(stats)).toHaveLength(1); + expect(getErrors(stats)[0]).toMatch(/the encoder gave up/); + expect(assets).not.toContain("image.webp"); + }); + + it("should surface what an asset generator reports", async () => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {EXPECTED_ANY} the result, with diagnostics + */ + function noisy(input) { + const [[name, code]] = Object.entries(input); + + return { + code: Buffer.from(code), + filename: replaceExtension(name, "webp"), + errors: ["could not read the colour profile"], + warnings: ["fell back to the default quality"], + }; + } + + noisy.supportsBinary = () => true; + noisy.supportsWorker = () => false; + + const { stats, assets } = await build({ + generate: { webp: { implementation: noisy, type: "asset" } }, + }); + + expect(getErrors(stats)).toHaveLength(1); + expect(getErrors(stats)[0]).toMatch(/could not read the colour profile/); + expect(getWarnings(stats)).toHaveLength(1); + expect(getWarnings(stats)[0]).toMatch(/fell back to the default quality/); + expect(assets).toContain("image.webp"); + }); + + it("should update an asset the generated name already names", async () => { + const webp = encoderNamed("WEBP", "webp"); + const { compiler, stats, assets } = await build({ + generate: { + webp: { + implementation: webp, + type: "asset", + // Resolves to the name the asset already has. + filename: "[name][ext]", + }, + }, + }); + + expect(getErrors(stats)).toEqual([]); + expect(assets.filter((name) => name === "image.jpg")).toHaveLength(1); + expect(assets).not.toContain("image.webp"); + expect(readAsset("image.jpg", compiler, stats).toString()).toMatch( + /^WEBP:/, + ); + }); + it("should skip an asset its `filter` declines", async () => { const webp = encoderNamed("WEBP", "webp"); const { stats, assets } = await build({ @@ -1360,6 +1431,33 @@ describe("generate option with the filesystem cache", () => { expect(second.assets).toContain("image.webp"); }); + it("should read every named generator for the identity", async () => { + // `asset` generators need no awaitable hook, so this runs everywhere and + // covers a salt built from more than one generator. + const options = { + generate: { + webp: { implementation: toWebp, type: "asset" }, + avif: { implementation: toAvif, type: "asset" }, + }, + }; + const first = await run(options); + + expect(getErrors(first.stats)).toEqual([]); + expect(first.assets).toContain("image.webp"); + expect(first.assets).toContain("image.avif"); + expect(toWebp.calls).toBe(1); + expect(toAvif.calls).toBe(1); + + toWebp.calls = 0; + toAvif.calls = 0; + + const second = await run(options); + + expect(getErrors(second.stats)).toEqual([]); + expect(second.assets).toContain("image.webp"); + expect(second.assets).toContain("image.avif"); + }); + it("should read an array of generators for the identity too", async () => { const first = await run({ generate: [toWebp] }); From 10107bc2db640430a53ea0f858e7d543fc514b94 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 5 Sep 2026 22:15:27 +0000 Subject: [PATCH 09/13] test: mix minimizers written both ways in one array An array may hold the minimizer itself beside an object describing one, and a described minimizer that names no options still reads the `minimizerOptions` entry at its index. Nothing drove either. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- test/minify-option.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/minify-option.test.js b/test/minify-option.test.js index a562327e..70188615 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1680,6 +1680,25 @@ describe("minify option written as an object", () => { expect(second.saw.tag).toBe("second"); }); + it("should mix minimizers written both ways in one array", async () => { + const plain = recorder(); + const described = recorder(); + const compiler = getCompiler(); + + new MinimizerPlugin({ + minify: [plain, { implementation: described }], + // Positional, and a descriptor that names no options of its own still + // reads the entry at its index. + minimizerOptions: [{ tag: "first" }, { tag: "second" }], + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(plain.saw.tag).toBe("first"); + expect(described.saw.tag).toBe("second"); + }); + it("should still take them from the deprecated `minimizerOptions`", async () => { const first = recorder(); const compiler = getCompiler(); From 04bc058bfa9eecd9ce4db96b3636ee65932ec4f6 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 6 Sep 2026 09:34:07 +0000 Subject: [PATCH 10/13] docs: give every example the new form The last examples still pairing `minify` with `minimizerOptions` now carry their options inside `minify`, the default minimizer named where it was implied. Prose that pointed at the option by name points at a minimizer's own `options` instead, and `normalizeMinimizers` gains the removal TODO its fallback was missing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- README.md | 77 +++++++++++++++++++++++++++------------------------- src/utils.js | 2 ++ 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 3c17b3bb..9dd6c76d 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,7 @@ the dispatcher (for example `test: /\.(?:[cm]?js|css|html?|json)(\?.*)?$/i`). ```js // Can be async const minify = (input, sourceMap, minimizerOptions, extractsComments) => { - // The `minimizerOptions` argument contains options from the `minimizerOptions` plugin option + // Whatever the `minify` option's `options` holds reaches the third argument // You can use `minimizerOptions.myCustomOption` // Custom logic for extract comments @@ -432,10 +432,10 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - myCustomOption: true, + minify: { + implementation: minify, + options: { myCustomOption: true }, }, - minify, }), ], }, @@ -559,7 +559,8 @@ and `module`, from the asset's own `javascriptModule` info or its `.mjs` / > > `terserOptions` is kept as a deprecated alias of `minimizerOptions` for > backwards compatibility — passing either is equivalent. If both are set, -> `minimizerOptions` wins. Prefer `minimizerOptions` in new code. +> `minimizerOptions` wins. Both are on their way out: prefer a minimizer's own +> `options` in new code. **webpack.config.js** @@ -569,21 +570,24 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - ecma: undefined, - parse: {}, - compress: {}, - mangle: true, // Note `mangle.properties` is `false` by default. - module: false, - // Deprecated - output: null, - format: null, - toplevel: false, - nameCache: null, - ie8: false, - keep_classnames: undefined, - keep_fnames: false, - safari10: false, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { + ecma: undefined, + parse: {}, + compress: {}, + mangle: true, // Note `mangle.properties` is `false` by default. + module: false, + // Deprecated + output: null, + format: null, + toplevel: false, + nameCache: null, + ie8: false, + keep_classnames: undefined, + keep_fnames: false, + safari10: false, + }, }, }), ], @@ -739,8 +743,8 @@ interchangeable: `ecma` is filled in from [`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) -unless a generator's options set it, the same way it is for -[`minimizerOptions`](#minimizeroptions). +unless a generator's options set it, the same way it is for a minimizer's — +see [`minimizerOptions`](#minimizeroptions). `filename` is a [webpack filename template](https://webpack.js.org/configuration/output/#outputfilename) @@ -833,7 +837,7 @@ By default, extract only comments using `/^\**!|@preserve|@license|@cc_on/i` Reg If the original file is named `foo.js`, then the comments will be stored to `foo.js.LICENSE.txt`. -The `minimizerOptions.format.comments` option specifies whether the comment will be preserved - i.e., it is possible to preserve some comments (e.g. annotations) while extracting others, or even preserve comments that have already been extracted. +A minimizer's `options.format.comments` specifies whether the comment will be preserved - i.e., it is possible to preserve some comments (e.g. annotations) while extracting others, or even preserve comments that have already been extracted. #### `boolean` @@ -1147,10 +1151,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - format: { - comments: /@license/i, - }, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { format: { comments: /@license/i } }, }, extractComments: true, }), @@ -1171,10 +1174,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - format: { - comments: false, - }, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { format: { comments: false } }, }, extractComments: false, }), @@ -1708,7 +1710,7 @@ through their own `filter`, and both run in the worker pool. | `filter` | `/\.css(\?.*)?$/i` | `/\.html(\?.*)?$/i` | | `supportsWorkerThreads()` | `true` | `true` | -`minimizerOptions` is `optimization.minimize.css` and +Their `options` are `optimization.minimize.css` and `optimization.minimize.html` respectively. `environment` carries what the target can read (`{ browsers, vendorPrefixes }`, the CSS entries of [`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment)), @@ -1791,7 +1793,7 @@ inline agree about the target: ```js const environment = { browsers: ["chrome 100", "safari 15"] }; -const minimizerOptions = [ +const options = [ {}, // cssMinify { environment, convertLengthUnits: true }, @@ -2202,10 +2204,10 @@ import banner from "./banner.png?width=320&quality=80"; A **flag** is on when it is present — `?flip` — and reads `true`/`1`/`yes` the same way, `false`/`0`/`no` the other. `greyscale` and `grey` spell `grayscale`, -`auto` on `width` or `height` drops one set in `minimizerOptions`, and a +`auto` on `width` or `height` drops one set in the minimizer's `options`, and a parameter can be spelled in any case. -Every one of these can also be set in `minimizerOptions`; the name wins where +Every one of these can also be set in the minimizer's `options`; the name wins where both say something, being the more specific of the two. `resize: { enabled: false }` still turns resizing off entirely. @@ -2358,8 +2360,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - compress: true, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { compress: true }, }, }), ], diff --git a/src/utils.js b/src/utils.js index f61fce11..d79513cd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2283,6 +2283,8 @@ function isDescriptor(entry) { * @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY }} the pair */ function normalizeMinimizers(minify, declared) { + // TODO drop the `declared` fallback in the next major release, with the + // deprecated `minimizerOptions` it carries. if (Array.isArray(minify)) { if (!minify.some(isDescriptor)) { return { implementation: minify, options: declared }; From f2a20bec24ca7c9abf70fd69d420f4d393e1ed43 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 6 Sep 2026 09:46:27 +0000 Subject: [PATCH 11/13] feat: one implementation per minimizer or generator object An object naming several implementations and pairing them with a list of options rebuilt the positional coupling the object form exists to remove: two lists that have to line up, in the one place a reader expects one thing configured. Several minimizers are an array of objects instead, each carrying its own options, and a generator names one implementation per preset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/minify-single-implementation.md | 5 ++ README.md | 68 +++++++++------------- src/options.json | 39 +------------ test/minify-option.test.js | 17 ++++++ 4 files changed, 52 insertions(+), 77 deletions(-) create mode 100644 .changeset/minify-single-implementation.md diff --git a/.changeset/minify-single-implementation.md b/.changeset/minify-single-implementation.md new file mode 100644 index 00000000..4dbb465a --- /dev/null +++ b/.changeset/minify-single-implementation.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +A minimizer or generator written as an object names one implementation; several are an array of such objects rather than one holding two lists that must line up. diff --git a/README.md b/README.md index 9dd6c76d..70bda765 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ type minifyFn = ( }>; interface minimizer { - implementation: minifyFn | minifyFn[]; + implementation: minifyFn; options?: Record; } @@ -335,7 +335,9 @@ By default plugin uses [terser](https://github.com/terser/terser) package. Useful for using and testing unpublished versions or forks. A minimizer can be written as an object instead, which is where its own -options live — one minimizer configured in one place: +options live — one minimizer configured in one place. Several minimizers are +an array of them, rather than one object holding two lists that have to line +up: ```js new MinimizerPlugin({ @@ -459,19 +461,13 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minify: { - implementation: [ - MinimizerPlugin.terserMinify, - MinimizerPlugin.swcMinify, - ], - // One entry per implementation, in the same order - options: [ - // Options for `MinimizerPlugin.terserMinify` - { mangle: false }, - // Options for `MinimizerPlugin.swcMinify` - {}, - ], - }, + minify: [ + { + implementation: MinimizerPlugin.terserMinify, + options: { mangle: false }, + }, + { implementation: MinimizerPlugin.swcMinify }, + ], }), ], }, @@ -612,7 +608,7 @@ type generateFn = ( }>; interface generator { - implementation: generateFn | generateFn[]; + implementation: generateFn; options?: Record; type?: "import" | "asset"; filename?: string; @@ -1683,16 +1679,12 @@ module.exports = { minimizer: [ new MinimizerPlugin({ test: /\.(?:[cm]?js|css|html|json)(\?.*)?$/i, - minify: { - implementation: [ - MinimizerPlugin.terserMinify, - cssMinify, - htmlMinify, - MinimizerPlugin.jsonMinify, - ], - // Positional: one entry per `minify` entry, in the same order. - options: [{}, {}, {}, {}], - }, + minify: [ + { implementation: MinimizerPlugin.terserMinify }, + { implementation: cssMinify }, + { implementation: htmlMinify }, + { implementation: MinimizerPlugin.jsonMinify }, + ], }), ], }, @@ -2299,21 +2291,15 @@ module.exports = { minimizer: [ new MinimizerPlugin({ test: /\.(js|css|svg|png|jpe?g)$/i, - minify: { - implementation: [ - MinimizerPlugin.terserMinify, - MinimizerPlugin.cssnanoMinify, - MinimizerPlugin.svgoMinify, - MinimizerPlugin.sharpMinify, - ], - // One entry per minimizer, in the same order - options: [ - {}, - {}, - {}, - { encodeOptions: { png: { compressionLevel: 9 } } }, - ], - }, + minify: [ + { implementation: MinimizerPlugin.terserMinify }, + { implementation: MinimizerPlugin.cssnanoMinify }, + { implementation: MinimizerPlugin.svgoMinify }, + { + implementation: MinimizerPlugin.sharpMinify, + options: { encodeOptions: { png: { compressionLevel: 9 } } }, + }, + ], }), ], }, diff --git a/src/options.json b/src/options.json index 836c5845..d1160eb6 100644 --- a/src/options.json +++ b/src/options.json @@ -225,18 +225,7 @@ "properties": { "implementation": { "description": "The minimizer itself.", - "anyOf": [ - { - "instanceof": "Function" - }, - { - "type": "array", - "minItems": 1, - "items": { - "instanceof": "Function" - } - } - ] + "instanceof": "Function" }, "options": { "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", @@ -268,18 +257,7 @@ "properties": { "implementation": { "description": "The generator itself.", - "anyOf": [ - { - "instanceof": "Function" - }, - { - "type": "array", - "minItems": 1, - "items": { - "instanceof": "Function" - } - } - ] + "instanceof": "Function" }, "options": { "description": "Options for this generator. Preferred over `generatorOptions`, which is deprecated; setting both for one generator is an error.", @@ -327,18 +305,7 @@ "properties": { "implementation": { "description": "The generator itself.", - "anyOf": [ - { - "instanceof": "Function" - }, - { - "type": "array", - "minItems": 1, - "items": { - "instanceof": "Function" - } - } - ] + "instanceof": "Function" }, "options": { "description": "Options for this generator. Preferred over `generatorOptions`, which is deprecated; setting both for one generator is an error.", diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 70188615..6d602629 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1714,6 +1714,23 @@ describe("minify option written as an object", () => { expect(first.saw.tag).toBe("from-minimizer-options"); }); + it("should reject a minimizer written as an object holding several", () => { + const first = recorder(); + const second = recorder(); + + expect(() => + getCompiler({ + plugins: [ + new MinimizerPlugin({ + // Several minimizers are an array of objects, not one object + // holding two lists that have to line up. + minify: { implementation: [first, second] }, + }), + ], + }), + ).toThrow(/should be an instance of function/); + }); + it("should reject options given in both places for one minimizer", () => { const first = recorder(); From a8ac3c3f59fac3534c7d054ffcd3a04ae75156ad Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 6 Sep 2026 10:26:58 +0000 Subject: [PATCH 12/13] docs: say what each generator `type` reads and produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table of the two side by side — what each reads, what it produces, what picks it, which fields it reads and which webpack it needs — then a worked example of each showing the files that come out. `filename`, `filter` and `deleteOriginalAssets` describe a file written beside another, so an `import` generator setting one is now an error. They were read only in `asset` mode, which made a config that looked complete quietly do half of nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- .changeset/generate-asset-only-fields.md | 5 ++ README.md | 67 ++++++++++++++++++++---- src/index.js | 28 ++++++++++ test/generate-option.test.js | 22 ++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 .changeset/generate-asset-only-fields.md diff --git a/.changeset/generate-asset-only-fields.md b/.changeset/generate-asset-only-fields.md new file mode 100644 index 00000000..78738524 --- /dev/null +++ b/.changeset/generate-asset-only-fields.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Reject `filename`, `filter` and `deleteOriginalAssets` on a generator that is not `type: "asset"`, where they did nothing. diff --git a/README.md b/README.md index 70bda765..1adc7f3b 100644 --- a/README.md +++ b/README.md @@ -727,15 +727,64 @@ new MinimizerPlugin({ ``` `type` decides which of the two things a generator does, and they are not -interchangeable: - -- `"import"` (the default) re-encodes a module **as it builds**, so the import - that asked for it is renamed with it. That is the only point at which a - rename can reach the bundle, and it needs the webpack noted below. -- `"asset"` writes a **new file beside one already emitted**, which nothing has - to import — a `.webp` next to a copied `.png`, say. Nothing points at it, so - `?as=` cannot reach it and it runs on any webpack. An asset already generated - is never generated from again. +interchangeable — they read different input, at different points in the build: + +| | `"import"` (the default) | `"asset"` | +| :----------------------------- | :----------------------------------------- | :------------------------------------------------------ | +| Reads | a module, **as it builds** | an asset, **once it is emitted** | +| Produces | that module's own bytes, renamed with them | a **new file beside** the one it read | +| Picked by | `?as=` on the import | `test` / `include` / `exclude`, then `filter` | +| Reaches a file nothing imports | no | yes — copied assets included | +| Fields it reads | `implementation`, `options` | those plus `filename`, `filter`, `deleteOriginalAssets` | +| webpack | **5.111** or newer | any supported version | + +**`"import"`** is the only point at which a rename can reach the bundle: the +asset is named while its module is built, so every reference follows it. The +import that asked for the conversion gets the converted file. + +```js +new MinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generate: { webp: { implementation: MinimizerPlugin.sharpGenerate } }, +}); +``` + +```js +import url from "./photo.jpg?as=webp"; // url is "photo.webp" +``` + +``` +photo.webp the jpg became this +``` + +**`"asset"`** leaves what it read alone and writes another file next to it, so +both survive — the `` case, where the `.webp` goes in a `srcset` you +write yourself and the `.jpg` stays as the fallback. Nothing imports the new +file, so `?as=` cannot reach it and its preset name selects nothing; the name +is only how you address its options. An asset already generated is never +generated from again. + +```js +new MinimizerPlugin({ + test: /\.(jpe?g|png)$/i, + generate: { + webp: { implementation: MinimizerPlugin.sharpGenerate, type: "asset" }, + }, +}); +``` + +```js +import url from "./photo.jpg"; // url is "photo.jpg", unchanged +``` + +``` +photo.jpg still there, unless `deleteOriginalAssets` +photo.webp generated beside it +``` + +`filename`, `filter` and `deleteOriginalAssets` describe a file being written +beside another, so they belong to `"asset"` and setting one on an `"import"` +generator is an error rather than a field that quietly does nothing. `ecma` is filled in from [`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment) diff --git a/src/index.js b/src/index.js index 54c3b88e..578905cb 100644 --- a/src/index.js +++ b/src/index.js @@ -1827,6 +1827,34 @@ class TerserPlugin { } } + // `filename`, `filter` and `deleteOriginalAssets` describe a file written + // beside another, which only an `asset` generator does. + for (const one of this.generators()) { + const misplaced = []; + + if (one.type !== "asset") { + if (typeof one.filename !== "undefined") { + misplaced.push("filename"); + } + + if (typeof one.filter !== "undefined") { + misplaced.push("filter"); + } + + if (typeof one.deleteOriginalAssets !== "undefined") { + misplaced.push("deleteOriginalAssets"); + } + } + + if (misplaced.length > 0) { + throw new Error( + `${misplaced.map((field) => `\`${field}\``).join(" and ")} in \`generate\`${ + typeof one.name === "undefined" ? "" : `'s '${one.name}'` + } ${misplaced.length === 1 ? "belongs" : "belong"} to a generator with \`type: "asset"\`, which writes a file beside the one it read. An \`import\` generator renames the module it re-encodes and reads neither.`, + ); + } + } + if (!named || !isPresets(declared)) { return; } diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 39c71719..f4fb0460 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1087,6 +1087,28 @@ describe("generate options", () => { }); } + it("should reject an asset-only field on an `import` generator", () => { + const webp = encoderNamed("WEBP", "webp"); + + expect(() => + construct({ + generate: { webp: { implementation: webp, filename: "[name].webp" } }, + }), + ).toThrow(/`filename` in `generate`'s 'webp' belongs to a generator with/); + + expect(() => + construct({ + generate: { + webp: { + implementation: webp, + filter: () => true, + deleteOriginalAssets: true, + }, + }, + }), + ).toThrow(/`filter` and `deleteOriginalAssets` in `generate`'s 'webp'/); + }); + it("should reject options given in both places for one generator", () => { const webp = encoderNamed("WEBP"); From 0e4c7f296f166da48a7b85b6e5c6c561d7a80f2c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 6 Sep 2026 11:06:38 +0000 Subject: [PATCH 13/13] test: cover `new URL()`, CSS `url()` and inlined references to a renamed asset An `"import"` generator renames the asset through `buildInfo.assetResource`, which every consumer of the asset module reads, so a `new URL()` and a CSS `url()` follow it just as an `import` does, and an inlined asset takes the media type of what it became. Only the `import` path was covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TQeNpahUSDUjD2Crugy5H --- README.md | 12 +++++ test/fixtures/css-image.js | 1 + test/fixtures/url-image.css | 3 ++ test/fixtures/url-image.js | 4 ++ test/generate-option.test.js | 87 ++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) create mode 100644 test/fixtures/css-image.js create mode 100644 test/fixtures/url-image.css create mode 100644 test/fixtures/url-image.js diff --git a/README.md b/README.md index 1adc7f3b..82e45b2c 100644 --- a/README.md +++ b/README.md @@ -751,12 +751,24 @@ new MinimizerPlugin({ ```js import url from "./photo.jpg?as=webp"; // url is "photo.webp" + +const same = new URL("./photo.jpg?as=webp", import.meta.url); // photo.webp +``` + +```css +.hero { + background: url("./photo.jpg?as=webp"); /* photo.webp */ +} ``` ``` photo.webp the jpg became this ``` +Those are one asset module between them, so an `import`, a `new URL()` and a +CSS `url()` all follow the rename. An asset inlined as a data URI carries no +file name, and takes the media type of what it became — `data:image/webp;…`. + **`"asset"`** leaves what it read alone and writes another file next to it, so both survive — the `` case, where the `.webp` goes in a `srcset` you write yourself and the `.jpg` stays as the fallback. Nothing imports the new diff --git a/test/fixtures/css-image.js b/test/fixtures/css-image.js new file mode 100644 index 00000000..77d1029b --- /dev/null +++ b/test/fixtures/css-image.js @@ -0,0 +1 @@ +import "./url-image.css"; diff --git a/test/fixtures/url-image.css b/test/fixtures/url-image.css new file mode 100644 index 00000000..968781c5 --- /dev/null +++ b/test/fixtures/url-image.css @@ -0,0 +1,3 @@ +.a { + background: url("./image.jpg?as=webp"); +} diff --git a/test/fixtures/url-image.js b/test/fixtures/url-image.js new file mode 100644 index 00000000..b5e5526b --- /dev/null +++ b/test/fixtures/url-image.js @@ -0,0 +1,4 @@ +const jpg = new URL("./image.jpg?as=webp", import.meta.url); + +// eslint-disable-next-line no-console +console.log(jpg); diff --git a/test/generate-option.test.js b/test/generate-option.test.js index f4fb0460..933956d7 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -259,6 +259,93 @@ describe("generate option", () => { expect(names).not.toContain("image.jpg?w=100#frag"); }); + it("should point a `new URL()` reference at the renamed asset", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/url-image.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ test: /\.jpe?g/i, generate: { webp: toWebp } }).apply( + compiler, + ); + + const stats = await compile(compiler); + + if (reportedNoAwait(stats)) { + return; + } + + const names = Object.keys(stats.compilation.assets); + + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.webp"); + expect(names).not.toContain("image.jpg"); + + // A `new URL()` reads the same asset module an `import` does, so the rename + // has to reach it too — it is the reference an image is usually behind. + const bundle = readBytes(compiler, stats, "main.js").toString(); + + expect(bundle).toContain('"image.webp"'); + expect(bundle).not.toContain('"image.jpg"'); + }); + + it("should point a CSS `url()` at the renamed asset", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/css-image.js"), + experiments: { css: true }, + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ test: /\.jpe?g/i, generate: { webp: toWebp } }).apply( + compiler, + ); + + const stats = await compile(compiler); + + if (reportedNoAwait(stats)) { + return; + } + + const names = Object.keys(stats.compilation.assets); + + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.webp"); + expect(names).not.toContain("image.jpg"); + + const styles = readBytes(compiler, stats, "main.css").toString(); + + expect(styles).toContain("image.webp"); + expect(styles).not.toContain("image.jpg"); + }); + + it("should give an inlined asset the media type of what it became", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/url-image.js"), + module: { + rules: [{ test: /\.(png|jpe?g|svg)$/i, type: "asset/inline" }], + }, + }); + + new MinimizerPlugin({ test: /\.jpe?g/i, generate: { webp: toWebp } }).apply( + compiler, + ); + + const stats = await compile(compiler); + + if (reportedNoAwait(stats)) { + return; + } + + expect(getErrors(stats)).toEqual([]); + + // Nothing is emitted for an inlined asset, so the rename shows up as the + // media type of the data URI rather than as a file name. + const bundle = readBytes(compiler, stats, "main.js").toString(); + + expect(bundle).toContain("data:image/webp;base64,"); + expect(bundle).not.toContain("data:image/jpeg"); + }); + it("should leave assets the filters reject alone", async () => { const compiler = getCompiler({ entry: path.resolve(__dirname, "./fixtures/images.js"),