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/060-minify-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"minimizer-webpack-plugin": minor
---

Allow a minimizer in `minify` to state its own `filter`.
69 changes: 67 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,22 @@ default one, and the tables in
[webpack's own minimizers](#webpacks-own-minimizers) for `cssMinify` and
`htmlMinify`.

`filter(name, info)` states which assets this minimizer is offered — return
`false` to decline one, and anything else (`undefined` included) to accept. It
answers for a `filter` property on the minimizer function itself, which is what
the built-ins carry, so setting it here is how you narrow one of them without
wrapping it.

```js
new MinimizerPlugin({
minify: {
implementation: MinimizerPlugin.sharpMinify,
options: { encodeOptions: { jpeg: { quality: 80 } } },
filter: (name) => !name.includes("do-not-touch"),
},
});
```

Two keys are filled in before a minimizer sees them, and only when `options`
does not set them itself: `ecma`, from
[`output.environment`](https://webpack.js.org/configuration/output/#outputenvironment),
Expand Down Expand Up @@ -441,7 +457,28 @@ module.exports = {
};
```

This is what lets **one plugin instance and one worker pool** handle every
Each entry carries its own `filter` as well as its own `options`, which is how
the same minimizer runs twice over different assets:

```js
new MinimizerPlugin({
test: /\.(jpe?g|png)$/i,
minify: [
{
implementation: MinimizerPlugin.sharpMinify,
options: { encodeOptions: { jpeg: { quality: 60 } } },
filter: (name) => name.includes("thumb"),
},
{
implementation: MinimizerPlugin.sharpMinify,
options: { encodeOptions: { jpeg: { quality: 90 } } },
filter: (name) => !name.includes("thumb"),
},
],
});
```

This is also what lets **one plugin instance and one worker pool** handle every
asset type: each built-in ships with a `filter` matching its natural
extension, so JS, CSS, HTML and JSON need no second instance. `test` still
defaults to JS only, so widen it to let the other assets reach the dispatcher:
Expand Down Expand Up @@ -1177,7 +1214,9 @@ What this reaches:
`exportType` but `link`).
- The text an `asset/source` module embeds, and the payload an `asset/inline`
module encodes — the payload before it is encoded, so the encoding covers
what came back.
what came back. **A language written as text only**: an inline `svg` is
offered, an inline `png` or `jpeg` is not, so a raster image that becomes a
`data:` URI is minified by [`generate`](#generate) rather than here.
- What a document or a stylesheet nests inside itself: an inline `<style>`,
every `style=""`, a `<script>` holding JavaScript or JSON, an `<svg>` subtree,
the document an `<iframe srcdoc>` holds, and the payload of a `url()` `data:`
Expand Down Expand Up @@ -1950,6 +1989,32 @@ npm install --save-dev imagemin imagemin-mozjpeg imagemin-pngquant
> run in the webpack process rather than in the worker pool — they do their own
> threading. Minimizers configured beside them keep the pool.

#### Images that never become files

`minify` runs over emitted assets, so an image that becomes a `data:` URI
instead of a file — `asset/inline`, or `asset` under
[`Rule.parser.dataUrlCondition`](https://webpack.js.org/configuration/module/#ruleparserdataurlcondition)
— never reaches it. An SVG is the exception, since it is text and webpack
offers it as [embedded source](#embedded-source).

Put the same minimizer under [`generate`](#generate) and it does reach one: a
generator runs while the module builds, over the bytes themselves, before
anything encodes them. It renames nothing unless it answers with a name, so a
minimizer stays a minimizer there.

```js
new MinimizerPlugin({
test: /\.(jpe?g|png)$/i,
generate: { implementation: MinimizerPlugin.sharpMinify },
});
```

Do not list the same image minimizer in both for the same assets — an emitted
one would then be re-encoded twice, which costs time and, for a lossy format,
quality. `generate` covers inlined and emitted images alike; `minify` is the
one to keep when nothing is inlined, since it runs after the whole build and
caches per asset. An `"import"` generator needs **webpack 5.111 or newer**.

#### Lossless and lossy

Images are optimized in one of two modes:
Expand Down
20 changes: 12 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ const {

/**
* @template T
* @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation<T>, options: MinimizerOptions<T> }, generator?: { implementation: MinimizerImplementation<T>, options: MinimizerOptions<T> } }} InternalPluginOptions
* @typedef {BasePluginOptions & { minimizer: { implementation: MinimizerImplementation<T>, options: MinimizerOptions<T>, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }, generator?: { implementation: MinimizerImplementation<T>, options: MinimizerOptions<T> } }} InternalPluginOptions
*/

const VALIDATION_CONFIGURATION = {
Expand Down Expand Up @@ -287,7 +287,7 @@ class TerserPlugin {
include,
exclude,
minimizer:
/** @type {{ implementation: MinimizerImplementation<T>, options: MinimizerOptions<T> }} */
/** @type {{ implementation: MinimizerImplementation<T>, options: MinimizerOptions<T>, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }} */
(normalizeMinimizers(minify, resolvedMinimizerOptions)),
// Absent unless asked for: it runs while modules build, where the plugin
// otherwise does nothing.
Expand Down Expand Up @@ -508,15 +508,19 @@ class TerserPlugin {
*/
const matchingMinimizers = (name, info) => {
const matched = [];
const { filters } = this.options.minimizer;

for (let i = 0; i < implementations.length; i++) {
const impl = implementations[i];

if (
typeof impl.filter !== "function" ||
// eslint-disable-next-line unicorn/no-array-method-this-argument
impl.filter(name, info) !== false
) {
// What `minify` states about this entry answers for it; the property on
// the function is what a minimizer says about itself, and is the
// fallback rather than a second filter to satisfy.
const filter =
filters && typeof filters[i] === "function"
? filters[i]
: impl.filter;

if (typeof filter !== "function" || filter(name, info) !== false) {
matched.push(i);
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/options.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@
"description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.",
"type": "object",
"additionalProperties": true
},
"filter": {
"description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.",
"instanceof": "Function"
}
},
"required": ["implementation"]
Expand All @@ -231,6 +235,10 @@
"description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.",
"type": "object",
"additionalProperties": true
},
"filter": {
"description": "Which assets this minimizer is offered, decided by name and info. Overrides a `filter` on the minimizer function itself.",
"instanceof": "Function"
}
},
"required": ["implementation"]
Expand Down
12 changes: 11 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2446,9 +2446,11 @@ function isDescriptor(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.
* `filters` is parallel to `implementation`, and holds only what a descriptor
* stated: an entry left undefined falls back to the function's own `filter`.
* @param {EXPECTED_ANY} minify what `minify` was set to
* @param {EXPECTED_ANY} declared what `minimizerOptions` says
* @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY }} the pair
* @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated
*/
function normalizeMinimizers(minify, declared) {
// TODO drop the `declared` fallback in the next major release, with the
Expand All @@ -2458,6 +2460,10 @@ function normalizeMinimizers(minify, declared) {
return { implementation: minify, options: declared };
}

const filters = minify.map((one) =>
isDescriptor(one) ? one.filter : undefined,
);

return {
implementation: minify.map((one) =>
isDescriptor(one) ? one.implementation : one,
Expand All @@ -2467,6 +2473,7 @@ function normalizeMinimizers(minify, declared) {
? one.options
: getMinimizerOptionsAt(declared, index),
),
...(filters.some((one) => typeof one === "function") ? { filters } : {}),
};
}

Expand All @@ -2475,6 +2482,9 @@ function normalizeMinimizers(minify, declared) {
implementation: minify.implementation,
options:
typeof minify.options === "undefined" ? declared : minify.options,
...(typeof minify.filter === "function"
? { filters: [minify.filter] }
: {}),
};
}

Expand Down
6 changes: 3 additions & 3 deletions test/__snapshots__/validate-options.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +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 | object { implementation, options? }, ...] (should not have fewer than 1 item) | object { implementation, options? }
function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? }
-> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included.
-> Read more at https://github.com/webpack/minimizer-webpack-plugin#number
Details:
* options.minify should be an instance of function.
* options.minify should be an array:
[function | object { implementation, options? }, ...] (should not have fewer than 1 item)
[function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item)
* options.minify should be an object:
object { implementation, options? }"
object { implementation, options?, filter? }"
`;

exports[`validation validate 11`] = `
Expand Down
119 changes: 119 additions & 0 deletions test/minify-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,125 @@ describe("minify option written as an object", () => {
expect(described.saw.tag).toBe("second");
});

/**
* @returns {EXPECTED_ANY} a minimizer that records the assets it was handed
*/
function watcher() {
/**
* @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 [[name, code]] = Object.entries(input);

minimize.seen.push(`${name}:${minimizerOptions.tag}`);

return { code };
}

minimize.supportsWorker = () => false;
minimize.seen = [];

return minimize;
}

/**
* @returns {import("webpack").Compiler} a compiler emitting `one.js` and `two.js`
*/
function twoAssets() {
return getCompiler({
entry: {
one: path.resolve(__dirname, "./fixtures/minify/es6.js"),
two: path.resolve(__dirname, "./fixtures/minify/es6.js"),
},
});
}

it("should offer a minimizer only what its own `filter` accepts", async () => {
const only = watcher();
const compiler = twoAssets();

new MinimizerPlugin({
minify: {
implementation: only,
options: { tag: "one" },
filter: (name) => name === "one.js",
},
}).apply(compiler);

const stats = await compile(compiler);

expect(getErrors(stats)).toEqual([]);
expect(only.seen).toEqual(["one.js:one"]);
});

it("should run one minimizer twice, each entry filtered and configured on its own", async () => {
const shared = watcher();
const compiler = twoAssets();

// The reason `filter` is a field rather than only a property on the
// function: two entries of the same minimizer cannot each carry their own.
new MinimizerPlugin({
minify: [
{
implementation: shared,
options: { tag: "first" },
filter: (name) => name === "one.js",
},
{
implementation: shared,
options: { tag: "second" },
filter: (name) => name === "two.js",
},
],
}).apply(compiler);

const stats = await compile(compiler);

expect(getErrors(stats)).toEqual([]);
expect(shared.seen.sort()).toEqual(["one.js:first", "two.js:second"]);
});

it("should let the `filter` in `minify` answer for one the function carries", async () => {
const declining = watcher();

declining.filter = () => false;

const compiler = twoAssets();

new MinimizerPlugin({
minify: {
implementation: declining,
options: { tag: "asked" },
filter: (name) => name === "two.js",
},
}).apply(compiler);

const stats = await compile(compiler);

expect(getErrors(stats)).toEqual([]);
expect(declining.seen).toEqual(["two.js:asked"]);
});

it("should fall back to the `filter` the function carries", async () => {
const own = watcher();

own.filter = (name) => name === "one.js";

const compiler = twoAssets();

new MinimizerPlugin({
minify: { implementation: own, options: { tag: "own" } },
}).apply(compiler);

const stats = await compile(compiler);

expect(getErrors(stats)).toEqual([]);
expect(own.seen).toEqual(["one.js:own"]);
});

it("should still take them from the deprecated `minimizerOptions`", async () => {
const first = recorder();
const compiler = getCompiler();
Expand Down
3 changes: 3 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,9 @@ type InternalPluginOptions<T> = BasePluginOptions & {
minimizer: {
implementation: MinimizerImplementation<T>;
options: MinimizerOptions<T>;
filters?: (
((name: string, info: AssetInfo) => boolean | undefined) | undefined
)[];
};
generator?: {
implementation: MinimizerImplementation<T>;
Expand Down
7 changes: 6 additions & 1 deletion types/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,16 +536,21 @@ export namespace napiRsImageMinify {
* Flattens the objects `minify` may hold into the implementation-and-options
* pair the rest of the plugin reads, so a descriptor's own `options` and the
* deprecated `minimizerOptions` end up in one place, aligned by position.
* `filters` is parallel to `implementation`, and holds only what a descriptor
* stated: an entry left undefined falls back to the function's own `filter`.
* @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
* @returns {{ implementation: EXPECTED_ANY, options: EXPECTED_ANY, filters?: (((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined)[] }} the pair, and the filters descriptors stated
*/
export function normalizeMinimizers(
minify: EXPECTED_ANY,
declared: EXPECTED_ANY,
): {
implementation: EXPECTED_ANY;
options: EXPECTED_ANY;
filters?: (
((name: string, info: EXPECTED_ANY) => boolean | undefined) | undefined
)[];
};
/**
* The version a package reports. Read by walking up from its resolved entry
Expand Down
Loading