From a5909fb11f23fe3ae91d9b82c1c1abe3acdbf849 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Fri, 4 Sep 2026 13:57:12 +0000 Subject: [PATCH] feat: add the imageminGenerate generator The last capability `image-minimizer-webpack-plugin` had and this one did not. `imageminMinify` cannot write a converted image: a plugin like `imagemin-webp` produces a format the asset's name does not claim, and `processAssets` is past the point where a name can change, so it keeps the original and warns. `generate` runs while the module builds, which is where the rename is still possible. Which format gets written is the plugins' decision rather than the caller's, so unlike `sharpGenerate` this one asks for no target: it reads the format back off the bytes and renames to match, and leaves an asset its plugins did not convert under the name it had. Query and fragment survive, since they name the asset too. Tested against `imagemin-svgo` over SVG markup carrying a raster name -- the mismatch `imageminMinify` refuses, which is the one a generator exists to take -- plus the unchanged-format and query cases and the contract it declares. It joins the version table too, which now covers both generators. --- .changeset/imagemin-generate.md | 5 +++ README.md | 23 ++++++++--- src/index.js | 2 + src/utils.js | 70 ++++++++++++++++++++++++++++++++ test/image-minify-option.test.js | 56 +++++++++++++++++++++++++ types/index.d.ts | 2 + types/utils.d.ts | 43 ++++++++++++++++++++ 7 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 .changeset/imagemin-generate.md diff --git a/.changeset/imagemin-generate.md b/.changeset/imagemin-generate.md new file mode 100644 index 00000000..84444f9a --- /dev/null +++ b/.changeset/imagemin-generate.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +add the `imageminGenerate` generator, which runs the `imagemin` plugins you name and renames the asset to the format they wrote diff --git a/README.md b/README.md index d879c645..082b319a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Image minimizers: - [`sharp`](https://github.com/lovell/sharp) — `MinimizerPlugin.sharpMinify`. Re-encodes an image as the format its name already claims (`avif`, `gif`, `heif`, `jp2`, `jpeg`, `png`, `tiff`, `webp`), and can resize, rotate, flip, grayscale, blur or sharpen on the way — from the options or from the asset's own name. Requires `npm install --save-dev sharp`. - [`svgo`](https://github.com/svg/svgo) — `MinimizerPlugin.svgoMinify`. Minifies SVG, including an `asset/inline` SVG that no `test` can match. Requires `npm install --save-dev svgo`. -- [`imagemin`](https://github.com/imagemin/imagemin) — `MinimizerPlugin.imageminMinify`. Runs the `imagemin` plugins you name. Requires `npm install --save-dev imagemin` plus each plugin. +- [`imagemin`](https://github.com/imagemin/imagemin) — `MinimizerPlugin.imageminMinify`. Runs the `imagemin` plugins you name. Requires `npm install --save-dev imagemin` plus each plugin. Pair it with `MinimizerPlugin.imageminGenerate` under [`generate`](#generate) when a plugin converts the format, since only that can rename the asset. - [`@napi-rs/image`](https://github.com/Brooooooklyn/Image) — `MinimizerPlugin.napiRsImageMinify`. Rust codecs with no system dependency; recompresses `png` losslessly with oxipng and `jpeg` with mozjpeg, and can resize, turn, mirror, grayscale, invert or blur on the way — from the options or from the asset's own name. Requires `npm install --save-dev @napi-rs/image`. These only minify — they never change an image's format or name; see @@ -589,10 +589,13 @@ modules it sees, matched against the module's resource — query and all, so modules build before the worker pool is up, and its answer is cached under the bytes plus the generator and its options. -`sharpGenerate` is the bundled one. It takes the target format 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 to write: +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 +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. ```js const MinimizerPlugin = require("minimizer-webpack-plugin"); @@ -1679,6 +1682,11 @@ Available image minimizers: - `MinimizerPlugin.sharpMinify` — uses [`sharp`](https://github.com/lovell/sharp), re-encoding each image as its own format. - `MinimizerPlugin.svgoMinify` — uses [`svgo`](https://github.com/svg/svgo). - `MinimizerPlugin.imageminMinify` — uses [`imagemin`](https://github.com/imagemin/imagemin) and the plugins you name. +- `MinimizerPlugin.napiRsImageMinify` — uses [`@napi-rs/image`](https://github.com/Brooooooklyn/Image), which ships prebuilt codecs. + +Converting an image to another format is [`generate`](#generate)'s job rather +than a minimizer's, since only that can rename the asset: +`MinimizerPlugin.sharpGenerate` and `MinimizerPlugin.imageminGenerate`. The image minimizers are optional peer dependencies — install only the ones you actually use: @@ -1917,7 +1925,10 @@ module.exports = { > > A plugin that converts — `imagemin-webp`, `imagemin-avif` — writes a format > the asset's name does not claim. `imageminMinify` keeps the original and -> warns instead of writing it, since it cannot rename the asset. +> warns instead of writing it, since it cannot rename the asset. Reach for +> [`generate`](#generate) with `MinimizerPlugin.imageminGenerate` to convert: +> it runs the same plugins while the module builds, which is where the asset +> can still take the name of the format they wrote. #### `@napi-rs/image` diff --git a/src/index.js b/src/index.js index 7c2138a2..90632335 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ const { getEcmaVersion, getMinimizerOptionsAt, htmlMinifierTerser, + imageminGenerate, imageminMinify, imageminNormalizeConfig, jsonMinify, @@ -1594,6 +1595,7 @@ TerserPlugin.cleanCssMinify = cleanCssMinify; TerserPlugin.esbuildMinifyCss = esbuildMinifyCss; TerserPlugin.lightningCssMinify = lightningCssMinify; TerserPlugin.swcMinifyCss = swcMinifyCss; +TerserPlugin.imageminGenerate = imageminGenerate; TerserPlugin.imageminMinify = imageminMinify; TerserPlugin.imageminNormalizeConfig = imageminNormalizeConfig; TerserPlugin.napiRsImageMinify = napiRsImageMinify; diff --git a/src/utils.js b/src/utils.js index 48a60cbf..ab4d66fa 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3134,6 +3134,75 @@ async function imageminMinify(input, sourceMap, minimizerOptions) { return { code: minified }; } +/* istanbul ignore next */ +/** + * Re-encode an image with `imagemin`, renaming it when a plugin wrote another + * format. + * + * Which format is written is the plugins' to decide -- `imagemin-webp` writes + * webp -- so it is read back off the bytes rather than asked for by name, and + * an asset a plugin left in its own format is simply not renamed. + * @param {Input} input input + * @param {RawSourceMap=} sourceMap source map (ignored for images) + * @param {CustomOptions=} minimizerOptions options + * @returns {Promise} generated result + */ +async function imageminGenerate(input, sourceMap, minimizerOptions) { + const [[name, code]] = Object.entries(input); + const normalized = await imageminNormalizeConfig(minimizerOptions); + const imagemin = (await getDynamicImport()("imagemin")).default; + const result = await imagemin.buffer( + toBuffer(code), + /** @type {EXPECTED_ANY} */ (normalized), + ); + // imagemin@8 answers with a Buffer, imagemin@9 with a Uint8Array. + const generated = Buffer.isBuffer(result) ? result : Buffer.from(result); + + const { canonicalExtension, fileTypeFromBuffer } = require("./fileType.js"); + + const inputExtension = canonicalExtension(extensionOf(name)); + const detected = fileTypeFromBuffer(generated); + const outputExtension = detected && canonicalExtension(detected.ext); + + if (outputExtension && inputExtension !== outputExtension) { + return { + code: generated, + filename: replaceExtension(name, outputExtension), + }; + } + + return { code: generated }; +} + +/** + * @returns {string | undefined} the minimizer version + */ +imageminGenerate.getMinimizerVersion = () => packageVersion("imagemin"); + +/** + * The asset reaches this one as bytes rather than as text. + * @returns {boolean} true, images are binary + */ +imageminGenerate.supportsBinary = () => true; + +/** + * Its plugins shell out to native binaries of their own, and its input cannot + * cross the worker boundary as text, so it stays in process. + * @returns {boolean} false + */ +imageminGenerate.supportsWorker = () => false; + +/** + * @returns {boolean} false + */ +imageminGenerate.supportsWorkerThreads = () => false; + +/** + * @param {string} name asset name + * @returns {boolean} true if `name` looks like an image + */ +imageminGenerate.filter = (name) => IMAGE_FILE_RE.test(name); + /** * @returns {string | undefined} the minimizer version */ @@ -3327,6 +3396,7 @@ module.exports = { getEcmaVersion, getMinimizerOptionsAt, htmlMinifierTerser, + imageminGenerate, imageminMinify, imageminNormalizeConfig, jsonMinify, diff --git a/test/image-minify-option.test.js b/test/image-minify-option.test.js index 47b71bfd..2f0ec9e0 100644 --- a/test/image-minify-option.test.js +++ b/test/image-minify-option.test.js @@ -1226,6 +1226,60 @@ describe("what an asset's name asks svgo for", () => { }); }); +describe("imageminGenerate", () => { + // SVG markup under a name claiming a raster format: the same mismatch + // `imageminMinify` refuses, which is the one a generator exists to take. + const svg = Buffer.from( + '', + ); + + it("should rename an image a plugin turned into SVG", async () => { + const { code, filename, warnings } = await MinimizerPlugin.imageminGenerate( + { "photo.png": svg }, + undefined, + { plugins: ["svgo"] }, + ); + + expect(warnings).toBeUndefined(); + expect(filename).toBe("photo.svg"); + // svgo ran: the padded coordinate is what it trims. + expect(code.toString()).not.toContain("1.00000"); + expect(code.toString()).toContain(" { + const { code, filename } = await MinimizerPlugin.imageminGenerate( + { "photo.svg": svg }, + undefined, + { plugins: ["svgo"] }, + ); + + expect(filename).toBeUndefined(); + expect(code.toString()).not.toContain("1.00000"); + }); + + it("should keep the query and fragment the name carried", async () => { + const { filename } = await MinimizerPlugin.imageminGenerate( + { "photo.png?w=100#frag": svg }, + undefined, + { plugins: ["svgo"] }, + ); + + expect(filename).toBe("photo.svg?w=100#frag"); + }); + + it("should declare what it needs from the plugin", () => { + expect(MinimizerPlugin.imageminGenerate.supportsBinary()).toBe(true); + // Its plugins shell out to native binaries, so it cannot leave the process. + expect(MinimizerPlugin.imageminGenerate.supportsWorker()).toBe(false); + expect(MinimizerPlugin.imageminGenerate.supportsWorkerThreads()).toBe( + false, + ); + expect(MinimizerPlugin.imageminGenerate.filter("photo.png")).toBe(true); + expect(MinimizerPlugin.imageminGenerate.filter("main.js")).toBe(false); + }); +}); + describe("the image minimizers' versions", () => { // `sharp`, `svgo` and `imagemin` do not list `./package.json` in their // `exports`, so requiring it throws and the version used to read as @@ -1235,6 +1289,8 @@ describe("the image minimizers' versions", () => { ["sharpMinify", MinimizerPlugin.sharpMinify], ["svgoMinify", MinimizerPlugin.svgoMinify], ["imageminMinify", MinimizerPlugin.imageminMinify], + ["imageminGenerate", MinimizerPlugin.imageminGenerate], + ["sharpGenerate", MinimizerPlugin.sharpGenerate], ["napiRsImageMinify", MinimizerPlugin.napiRsImageMinify], ])("should be what %s reports", (_name, minimizer) => { expect(minimizer.getMinimizerVersion()).toMatch(/^\d+\.\d+\.\d+/); diff --git a/types/index.d.ts b/types/index.d.ts index 71c6f392..4898d173 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -149,6 +149,7 @@ declare namespace TerserPlugin { esbuildMinifyCss, lightningCssMinify, swcMinifyCss, + imageminGenerate, imageminMinify, imageminNormalizeConfig, napiRsImageMinify, @@ -210,6 +211,7 @@ import { cleanCssMinify } from "./utils"; import { esbuildMinifyCss } from "./utils"; import { lightningCssMinify } from "./utils"; import { swcMinifyCss } from "./utils"; +import { imageminGenerate } from "./utils"; import { imageminMinify } from "./utils"; import { imageminNormalizeConfig } from "./utils"; import { napiRsImageMinify } from "./utils"; diff --git a/types/utils.d.ts b/types/utils.d.ts index 543979a7..025ee95e 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -246,6 +246,49 @@ export namespace htmlMinifierTerser { */ function filter(name: string): boolean; } +/** + * Re-encode an image with `imagemin`, renaming it when a plugin wrote another + * format. + * + * Which format is written is the plugins' to decide -- `imagemin-webp` writes + * webp -- so it is read back off the bytes rather than asked for by name, and + * an asset a plugin left in its own format is simply not renamed. + * @param {Input} input input + * @param {RawSourceMap=} sourceMap source map (ignored for images) + * @param {CustomOptions=} minimizerOptions options + * @returns {Promise} generated result + */ +export function imageminGenerate( + input: Input, + sourceMap?: RawSourceMap | undefined, + minimizerOptions?: CustomOptions | undefined, +): Promise; +export namespace imageminGenerate { + /** + * @returns {string | undefined} the minimizer version + */ + function getMinimizerVersion(): string | undefined; + /** + * The asset reaches this one as bytes rather than as text. + * @returns {boolean} true, images are binary + */ + function supportsBinary(): boolean; + /** + * Its plugins shell out to native binaries of their own, and its input cannot + * cross the worker boundary as text, so it stays in process. + * @returns {boolean} false + */ + function supportsWorker(): boolean; + /** + * @returns {boolean} false + */ + function supportsWorkerThreads(): boolean; + /** + * @param {string} name asset name + * @returns {boolean} true if `name` looks like an image + */ + function filter(name: string): boolean; +} /** * Minify an image using `imagemin` and the plugins named in the options. * @param {Input} input input