Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/imagemin-generate.md
Original file line number Diff line number Diff line change
@@ -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
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
getEcmaVersion,
getMinimizerOptionsAt,
htmlMinifierTerser,
imageminGenerate,
imageminMinify,
imageminNormalizeConfig,
jsonMinify,
Expand Down Expand Up @@ -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;
Expand Down
70 changes: 70 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<MinimizedResult>} 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
*/
Expand Down Expand Up @@ -3327,6 +3396,7 @@ module.exports = {
getEcmaVersion,
getMinimizerOptionsAt,
htmlMinifierTerser,
imageminGenerate,
imageminMinify,
imageminNormalizeConfig,
jsonMinify,
Expand Down
56 changes: 56 additions & 0 deletions test/image-minify-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><rect x="1.00000"/></svg>',
);

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("<svg");
});

it("should keep the name when the format did not change", async () => {
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
Expand All @@ -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+/);
Expand Down
2 changes: 2 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ declare namespace TerserPlugin {
esbuildMinifyCss,
lightningCssMinify,
swcMinifyCss,
imageminGenerate,
imageminMinify,
imageminNormalizeConfig,
napiRsImageMinify,
Expand Down Expand Up @@ -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";
Expand Down
43 changes: 43 additions & 0 deletions types/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MinimizedResult>} generated result
*/
export function imageminGenerate(
input: Input,
sourceMap?: RawSourceMap | undefined,
minimizerOptions?: CustomOptions | undefined,
): Promise<MinimizedResult>;
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
Expand Down
Loading