diff --git a/.changeset/generate-asset-only-fields.md b/.changeset/generate-asset-only-fields.md new file mode 100644 index 0000000..7873852 --- /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/.changeset/generate-asset-size.md b/.changeset/generate-asset-size.md new file mode 100644 index 0000000..ef8d3f9 --- /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/.changeset/generate-asset-type.md b/.changeset/generate-asset-type.md new file mode 100644 index 0000000..7a88556 --- /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/.changeset/generate-own-options.md b/.changeset/generate-own-options.md new file mode 100644 index 0000000..ff10f0b --- /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/.changeset/generate-presets.md b/.changeset/generate-presets.md new file mode 100644 index 0000000..11e0907 --- /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/.changeset/minify-own-options.md b/.changeset/minify-own-options.md new file mode 100644 index 0000000..116584c --- /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/.changeset/minify-single-implementation.md b/.changeset/minify-single-implementation.md new file mode 100644 index 0000000..4dbb465 --- /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/.cspell.json b/.cspell.json index 7d7c7a9..aa754b3 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 4696f07..82e45b2 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` @@ -321,7 +320,12 @@ type minifyFn = ( extractedComments?: string[] | undefined; }>; -type minify = minifyFn | minifyFn[]; +interface minimizer { + implementation: minifyFn; + options?: Record; +} + +type minify = minifyFn | (minifyFn | minimizer)[] | minimizer; ``` Default: `MinimizerPlugin.terserMinify` @@ -330,6 +334,20 @@ 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. Several minimizers are +an array of them, rather than one object holding two lists that have to line +up: + +```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` @@ -353,8 +371,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 @@ -372,7 +391,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 @@ -415,10 +434,10 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - myCustomOption: true, + minify: { + implementation: minify, + options: { myCustomOption: true }, }, - minify, }), ], }, @@ -430,10 +449,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** @@ -443,13 +461,12 @@ 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, + options: { mangle: false }, + }, + { implementation: MinimizerPlugin.swcMinify }, ], }), ], @@ -511,13 +528,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 @@ -529,7 +555,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** @@ -539,21 +566,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, + }, }, }), ], @@ -577,7 +607,20 @@ type generateFn = ( warnings?: (Error | string)[]; }>; -type generate = generateFn | generateFn[]; +interface generator { + implementation: generateFn; + options?: Record; + type?: "import" | "asset"; + filename?: string; + filter?: (name: string) => boolean; + deleteOriginalAssets?: boolean; +} + +type generate = + | generateFn + | generateFn[] + | generator + | Record; ``` Default: `undefined` @@ -597,8 +640,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. @@ -632,6 +674,141 @@ 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: { + implementation: MinimizerPlugin.sharpGenerate, + options: { encodeOptions: { webp: { quality: 90 } } }, + }, + avif: { + implementation: MinimizerPlugin.sharpGenerate, + options: { 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. + +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({ + test: /\.(jpe?g|png)$/i, + generate: { + webp: { + implementation: MinimizerPlugin.sharpGenerate, + 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. + 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, + }, + }, +}); +``` + +`type` decides which of the two things a generator does, and they are not +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" + +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 +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) +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) +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 @@ -640,7 +817,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 @@ -651,45 +828,21 @@ 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. - -### `generatorOptions` - -Type: - -```ts -type generatorOptions = Record | Record[]; -``` - -Default: `{}` - -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. - -`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"); +> **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. -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 } } }, - }), - ], -}; -``` +> **Note** +> +> 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. ### `extractComments` @@ -741,7 +894,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` @@ -1055,10 +1208,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - format: { - comments: /@license/i, - }, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { format: { comments: /@license/i } }, }, extractComments: true, }), @@ -1079,10 +1231,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - format: { - comments: false, - }, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { format: { comments: false } }, }, extractComments: false, }), @@ -1103,10 +1254,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: {}, + }, }), ], }, @@ -1131,10 +1284,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: {}, + }, }), ], }, @@ -1157,17 +1312,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: {}, + }, }), ], }, @@ -1190,9 +1347,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: {}, + }, }), ], }, @@ -1255,11 +1414,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, + }, }, }), ], @@ -1283,9 +1444,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: {}, + }, }), ], }, @@ -1308,9 +1471,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: {}, + }, }), ], }, @@ -1338,9 +1503,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: {}, + }, }), ], }, @@ -1404,10 +1571,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", + }, }, }), ], @@ -1431,9 +1600,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: {}, + }, }), ], }, @@ -1456,9 +1627,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: {}, + }, }), ], }, @@ -1481,9 +1654,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: {}, + }, }), ], }, @@ -1506,9 +1681,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: {}, + }, }), ], }, @@ -1531,9 +1708,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: {}, + }, }), ], }, @@ -1562,13 +1741,11 @@ module.exports = { new MinimizerPlugin({ test: /\.(?:[cm]?js|css|html|json)(\?.*)?$/i, minify: [ - MinimizerPlugin.terserMinify, - cssMinify, - htmlMinify, - MinimizerPlugin.jsonMinify, + { implementation: MinimizerPlugin.terserMinify }, + { implementation: cssMinify }, + { implementation: htmlMinify }, + { implementation: MinimizerPlugin.jsonMinify }, ], - // Positional: one entry per `minify` entry, in the same order. - minimizerOptions: [{}, {}, {}, {}], }), ], }, @@ -1586,7 +1763,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)), @@ -1669,7 +1846,7 @@ inline agree about the target: ```js const environment = { browsers: ["chrome 100", "safari 15"] }; -const minimizerOptions = [ +const options = [ {}, // cssMinify { environment, convertLengthUnits: true }, @@ -1784,15 +1961,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: {}, + }, }, }, }); @@ -1803,14 +1982,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 + }, }, }, }); @@ -1841,14 +2022,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 }, + }, }, }, }), @@ -1862,21 +2045,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 } }, + }, }, }); ``` @@ -1903,12 +2088,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"], + }, }, }, }), @@ -1935,14 +2122,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", + ], + }, }, }), ], @@ -1984,19 +2173,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 }, + }, }, }, }), @@ -2066,10 +2257,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. @@ -2162,17 +2353,13 @@ module.exports = { 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 } } }, + { implementation: MinimizerPlugin.terserMinify }, + { implementation: MinimizerPlugin.cssnanoMinify }, + { implementation: MinimizerPlugin.svgoMinify }, + { + implementation: MinimizerPlugin.sharpMinify, + options: { encodeOptions: { png: { compressionLevel: 9 } } }, + }, ], }), ], @@ -2220,8 +2407,9 @@ module.exports = { minimize: true, minimizer: [ new MinimizerPlugin({ - minimizerOptions: { - compress: true, + minify: { + implementation: MinimizerPlugin.terserMinify, + options: { compress: true }, }, }), ], @@ -2252,96 +2440,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 + }, }, }), ], @@ -2349,6 +2563,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` | `options` on that minimizer | +| `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`, +`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, + options: { encodeOptions: { webp: { quality: 90 } } }, + }, + }, + }), + ], +}; +``` + ## Contributing We welcome all contributions! diff --git a/src/index.js b/src/index.js index 03c1d90..578905c 100644 --- a/src/index.js +++ b/src/index.js @@ -15,11 +15,16 @@ const { imageminGenerate, imageminMinify, imageminNormalizeConfig, + interpolateSize, + isDescriptor, + isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, + normalizeMinimizers, + readPreset, sharpGenerate, sharpMinify, svgoMinify, @@ -123,6 +128,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 @@ -279,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 @@ -1130,6 +1136,138 @@ 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 = isDescriptor(entry) ? entry : undefined; + const own = descriptor ? descriptor.options : undefined; + + 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, + 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) || isDescriptor(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` 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 found = this.generators(); + const named = found.filter((one) => typeof one.name !== "undefined"); + let generator; + + if (named.length === 0) { + [generator] = found; + } else { + const asked = readPreset(resource); + + if (!asked) { + return undefined; + } + + generator = named.find((one) => one.name === asked); + + 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, + ), + ); + + return undefined; + } + } + + // An `asset` generator runs over what is emitted, so nothing imports it + // and `?as=` cannot reach it. + if (!generator || generator.type === "asset") { + return undefined; + } + + return { + implementation: generator.implementation, + options: generator.options, + }; + } + + /** + * 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() { + return this.generators().some((one) => one.type !== "asset"); + } + + /** + * The generators that run over emitted assets rather than over a module as + * it builds. + * @private + * @returns {ReturnType[]} them, in the order they were written + */ + assetGenerators() { + return this.generators().filter((one) => one.type === "asset"); + } + /** * 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,23 +1284,205 @@ class TerserPlugin { return; } - const implementations = Array.isArray(generator.implementation) - ? generator.implementation - : [generator.implementation]; - // 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)}`; } + /** + * 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, width?: number, height?: number, 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, + width: generated.width, + height: generated.height, + 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 + ? 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. + 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 @@ -1330,10 +1650,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; } @@ -1425,6 +1750,128 @@ 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. + * @private + * @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) { + return; + } + + const written = generator.implementation; + const declared = generator.options; + const named = isPresets(written) && !isDescriptor(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 = isDescriptor(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\`.`, + ); + } + } + + // `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; + } + + 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 @@ -1438,6 +1885,8 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateMinimizers(); + this.validateGenerators(); return; } @@ -1451,6 +1900,8 @@ class TerserPlugin { this.rawOptions, VALIDATION_CONFIGURATION, ); + this.validateMinimizers(); + this.validateGenerators(); } /** @@ -1546,15 +1997,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 = @@ -1597,6 +2052,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/minify.js b/src/minify.js index c2b9b5b..e704e43 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/options.json b/src/options.json index e32b927..d1160eb 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,13 +196,49 @@ "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.", + "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"] } ] }, "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. 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": [ { @@ -214,11 +250,95 @@ "items": { "instanceof": "Function" } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The generator itself.", + "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, + "additionalProperties": { + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "array", + "minItems": 1, + "items": { + "instanceof": "Function" + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "implementation": { + "description": "The generator itself.", + "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"] + } + ] + } } ] }, "generatorOptions": { - "description": "Options for the `generate` function.", + "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": [ { diff --git a/src/utils.js b/src/utils.js index ab4d66f..d79513c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2228,6 +2228,112 @@ 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) + ); +} + +/** + * 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 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 isDescriptor(entry) { + return ( + typeof entry === "object" && + entry !== null && + !Array.isArray(entry) && + typeof entry.implementation !== "undefined" + ); +} + +/** + * 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) { + // 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 }; + } + + 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 + * @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 @@ -2478,11 +2584,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 */ @@ -3399,12 +3512,17 @@ module.exports = { imageminGenerate, imageminMinify, imageminNormalizeConfig, + interpolateSize, + isDescriptor, + isPresets, jsonMinify, lightningCssMinify, memoize, minifyHtmlNode, napiRsImageMinify, + normalizeMinimizers, packageVersion, + readPreset, replaceExtension, sharpGenerate, sharpMinify, diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index bca22f6..3c9f4c7 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/fixtures/css-image.js b/test/fixtures/css-image.js new file mode 100644 index 0000000..77d1029 --- /dev/null +++ b/test/fixtures/css-image.js @@ -0,0 +1 @@ +import "./url-image.css"; diff --git a/test/fixtures/preset-image.js b/test/fixtures/preset-image.js new file mode 100644 index 0000000..3b53cf7 --- /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 0000000..0759a65 --- /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/fixtures/url-image.css b/test/fixtures/url-image.css new file mode 100644 index 0000000..968781c --- /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 0000000..b5e5526 --- /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 d063815..933956d 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -3,9 +3,15 @@ import os from "os"; import path from "path"; import MinimizerPlugin from "../src"; -import { replaceExtension } from "../src/utils"; - -import { compile, getCompiler, getErrors, getWarnings } from "./helpers"; +import { readPreset, replaceExtension } from "../src/utils"; + +import { + compile, + getCompiler, + getErrors, + getWarnings, + readAsset, +} from "./helpers"; import { RUN_IMAGE_TESTS } from "./helpers/env"; /** @@ -253,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"), @@ -476,6 +569,656 @@ 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("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 + * "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 { + ...encode.reports, + code: Buffer.concat([Buffer.from(`${tag}:`), Buffer.from(code)]), + filename: replaceExtension(name, extension), + }; + } + + encode.supportsBinary = () => true; + encode.supportsWorker = () => false; + encode.calls = 0; + encode.reports = {}; + + 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 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({ + 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 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({ + 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 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 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"); + + 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; @@ -797,6 +1540,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] }); diff --git a/test/minify-option.test.js b/test/minify-option.test.js index a68e206..6d60262 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1621,3 +1621,128 @@ 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 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(); + + 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 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(); + + 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 4ff76e3..679938a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -89,6 +89,48 @@ 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` 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 + */ + 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 generators that run over emitted assets rather than over a module as + * it builds. + * @private + * @returns {ReturnType[]} 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 @@ -98,6 +140,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 @@ -128,6 +192,20 @@ 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. + * @private + * @returns {void} + */ + private validateGenerators; /** * Validates the options the plugin was constructed with. * @private @@ -383,6 +461,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 025ee95..08d31a1 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -336,6 +336,36 @@ 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 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 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 + * 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 @@ -478,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 @@ -488,6 +533,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.