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