diff --git a/.changeset/generator-compression-options.md b/.changeset/generator-compression-options.md new file mode 100644 index 00000000..f235fee8 --- /dev/null +++ b/.changeset/generator-compression-options.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Add `threshold`, `minRatio` and `relatedName` to an `asset` generator, and stop `minify` from taking `false`. diff --git a/src/index.js b/src/index.js index 7f7afaca..edcc2897 100644 --- a/src/index.js +++ b/src/index.js @@ -205,6 +205,26 @@ const { * @typedef {undefined | boolean | number} Parallel */ +/** + * One generator, written as an object stating how to run it. + * @typedef {object} GeneratorDescriptor + * @property {MinimizerImplementation} implementation the generator itself + * @property {MinimizerOptions=} options options for this generator, preferred over the deprecated `generatorOptions` + * @property {("import" | "asset")=} type `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted + * @property {string=} filename name for the generated asset, as a webpack filename template. `asset` generators only + * @property {((name: string) => boolean)=} filter decides per asset whether to generate from it, on top of `test`/`include`/`exclude` + * @property {boolean=} deleteOriginalAssets removes the asset generated from. `asset` generators only + * @property {number=} threshold generate only from assets larger than this, in bytes. `asset` generators only + * @property {number=} minRatio keep the generated asset only when it is this much smaller than the one it was read from. `asset` generators only + * @property {(string | false)=} relatedName the key the generated asset is recorded under in the original's `related` info. `asset` generators only + */ + +/** + * What `generate` may be written as: one generator, a list of them, a + * descriptor, or an object naming descriptors an asset asks for with `?as=`. + * @typedef {MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor | { [preset: string]: MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor }} Generate + */ + /** * @typedef {object} BasePluginOptions * @property {Rules=} test test rule @@ -212,7 +232,7 @@ const { * @property {Rules=} exclude exclude rule * @property {ExtractCommentsOptions=} extractComments extract comments options * @property {Parallel=} parallel parallel option - * @property {MinimizerImplementation=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset + * @property {Generate=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset, or writes a new file beside one already emitted * @property {MinimizerOptions=} generatorOptions options for `generate` */ @@ -256,6 +276,34 @@ const declaredStage = (compiler, implementation) => { return latest; }; +/** + * The name a chunk's JavaScript asset will take, as far as it is knowable + * while the hash that name contains is still being computed. + * @param {Compilation} compilation compilation + * @param {import("webpack").Chunk} chunk chunk + * @returns {string | undefined} the name, or undefined where a function names it + */ +const chunkAssetName = (compilation, chunk) => { + const { outputOptions } = compilation; + const template = + chunk.filenameTemplate || + (chunk.canBeInitial() + ? outputOptions.filename + : outputOptions.chunkFilename); + + if (typeof template !== "string") { + return undefined; + } + + // Every placeholder stands for text it has not got yet: what is being asked + // of the name is its path and extension, which none of them carries. + return template + .replace(/\[(?:full|chunk|content)hash(?::\d+)?]/gi, "0") + .replace(/\[name]/gi, String(chunk.name || chunk.id)) + .replace(/\[id]/gi, String(chunk.id)) + .replace(/\[runtime]/gi, String(chunk.runtime)); +}; + /** * The names an implementation's work goes under in an asset's info, which is * the union where several ran as one chain. @@ -331,12 +379,10 @@ class MinimizerPlugin { // TODO handle json and etc in the next major release // TODO make `minimizer` option instead `minify` and `terserOptions` in the next major release, also rename `terserMinify` to `terserMinimize` const { - minify = /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) - ), + minify: declaredMinify, minimizerOptions, terserOptions, - test = /\.[cm]?js(\?.*)?$/i, + test: declaredTest, extractComments = true, parallel = true, include, @@ -345,6 +391,17 @@ class MinimizerPlugin { generatorOptions, } = this.rawOptions; + // The JavaScript minifier and the names it reads are what this plugin is, + // so both stand whether or not a generator was configured too. + const minimizers = + typeof declaredMinify !== "undefined" + ? declaredMinify + : /** @type {MinimizerImplementation} */ ( + /** @type {unknown} */ (terserMinify) + ); + const test = + typeof declaredTest !== "undefined" ? declaredTest : /\.[cm]?js(\?.*)?$/i; + // `terserOptions` is a deprecated alias of `minimizerOptions`; prefer the // new name when both are provided. const resolvedMinimizerOptions = @@ -367,7 +424,7 @@ class MinimizerPlugin { exclude, minimizer: /** @type {{ implementation: MinimizerImplementation, options: MinimizerOptions, filters?: (((name: string, info: AssetInfo) => boolean | undefined) | undefined)[] }} */ - (normalizeMinimizers(minify, resolvedMinimizerOptions)), + (normalizeMinimizers(minimizers, resolvedMinimizerOptions)), // Absent unless asked for: it runs while modules build, where the plugin // otherwise does nothing. generator: generate @@ -587,6 +644,10 @@ class MinimizerPlugin { declaredFlags(one, "minimized"), ); + // `additionalAssets` hands this pass whatever was emitted after it ran, + // this plugin's own generated files included, and those are not to minify. + const generated = this.generatedFlags(); + /** * Remember what this plugin marked an asset with, which is what a later * pass of it reads back rather than declining. @@ -665,6 +726,12 @@ class MinimizerPlugin { return false; } + const says = /** @type {Record} */ (info); + + if (generated.some((flag) => says[flag])) { + return false; + } + if (!matchesName(name)) { return false; } @@ -1288,7 +1355,7 @@ class MinimizerPlugin { * @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 + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ describeGenerator(name, entry, declared) { const descriptor = isDescriptor(entry) ? entry : undefined; @@ -1306,6 +1373,9 @@ class MinimizerPlugin { deleteOriginalAssets: descriptor ? descriptor.deleteOriginalAssets : undefined, + threshold: descriptor ? descriptor.threshold : undefined, + minRatio: descriptor ? descriptor.minRatio : undefined, + relatedName: descriptor ? descriptor.relatedName : undefined, }; } @@ -1430,6 +1500,27 @@ class MinimizerPlugin { return flags; } + /** + * Every name this plugin's `asset` generators mark what they wrote with, + * which is how both passes tell a generated file from one to work on. + * @private + * @returns {string[]} the names + */ + generatedFlags() { + /** @type {string[]} */ + const flags = []; + + for (const one of this.assetGenerators()) { + for (const flag of declaredFlags(one.implementation, "generated")) { + if (!flags.includes(flag)) { + flags.push(flag); + } + } + } + + return flags; + } + /** * The generators that run over emitted assets rather than over a module as * it builds. @@ -1501,6 +1592,19 @@ class MinimizerPlugin { // `buffer()` rather than `source()`, which answers a source holding text // and bytes at once with a string: every byte over 0x7f is lost in that. const input = source.buffer(); + const says = /** @type {Record} */ (info); + + // Too small to be worth a second file, and one already recorded under this + // generator's key has been through it. + if ( + input.length < (generator.threshold || 0) || + (generator.relatedName && + says.related && + says.related[generator.relatedName]) + ) { + return; + } + // 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( @@ -1610,9 +1714,21 @@ class MinimizerPlugin { return; } const generatedSource = output.source; - // 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 }; + + // Not enough smaller to be worth serving: a second file that saves nothing + // still costs a request and a place in the cache. + if ( + typeof generator.minRatio === "number" && + generatedSource.size() / input.length > generator.minRatio + ) { + return; + } + + // A new file rather than a rewritten one, so it inherits nothing: what the + // original's info says of its hashes, its module and where its source came + // from is true of that file and not of this one. + /** @type {AssetInfo} */ + const generatedInfo = {}; // The name this generator works under, which is `generated` where it says // nothing and `compressed` for `compress`. @@ -1620,18 +1736,46 @@ class MinimizerPlugin { /** @type {Record} */ (generatedInfo)[flag] = true; } - delete generatedInfo.related; + // The exception, and only where the name it was given still derives from + // the original's: that is what carried the hash the promise rests on. + if ( + info.immutable && + typeof generator.filename === "string" && + /(\[name]|\[base]|\[file])/.test(generator.filename) + ) { + generatedInfo.immutable = true; + } + // Handed over as a function, which replaces: an object is merged into what + // the name carried before, and this file inherits nothing. if (compilation.getAsset(generatedName)) { - compilation.updateAsset(generatedName, generatedSource, generatedInfo); + compilation.updateAsset( + generatedName, + generatedSource, + () => generatedInfo, + ); + } else { + compilation.emitAsset(generatedName, generatedSource, generatedInfo); + } + + if (generator.deleteOriginalAssets) { + // Deleting an asset takes everything its `related` names with it, so + // recording this file there first would delete the file just written. + // A generator writing under the original's own name leaves nothing to + // delete either: that file is now the generated one. + if (generatedName !== name && compilation.getAsset(name)) { + compilation.deleteAsset(name); + } return; } - compilation.emitAsset(generatedName, generatedSource, generatedInfo); - - if (generator.deleteOriginalAssets && compilation.getAsset(name)) { - compilation.deleteAsset(name); + // Recorded on the asset it was read from, which is how a server asked for + // that one finds this one. + if (generator.relatedName) { + compilation.updateAsset(name, source, { + related: { [generator.relatedName]: generatedName }, + }); } } @@ -1643,25 +1787,17 @@ class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {ReturnType} generators the generators running at this stage + * @param {Record} assets the assets this pass was handed * @returns {Promise} */ - async generateAssets(compiler, compilation, generators) { + async generateAssets(compiler, compilation, generators, assets) { const cache = compilation.getCache("TerserWebpackPlugin|generateAssets"); const scheduled = []; - // Every name this plugin's generators work under, so none of them reads a - // file another one wrote — whichever name that one marked it with. - /** @type {string[]} */ - const produced = []; + // So no generator reads a file another one wrote, whichever name that one + // marked it with. + const produced = this.generatedFlags(); - for (const one of this.assetGenerators()) { - for (const flag of declaredFlags(one.implementation, "generated")) { - if (!produced.includes(flag)) { - produced.push(flag); - } - } - } - - for (const name of Object.keys(compilation.assets)) { + for (const name of Object.keys(assets)) { const asset = compilation.getAsset(name); if (!asset || !this.matchesName(compiler, name)) { @@ -2092,6 +2228,16 @@ class MinimizerPlugin { if (typeof one.deleteOriginalAssets !== "undefined") { misplaced.push("deleteOriginalAssets"); } + + for (const field of ["threshold", "minRatio", "relatedName"]) { + if ( + typeof ( + /** @type {Record} */ (one)[field] + ) !== "undefined" + ) { + misplaced.push(field); + } + } } if (misplaced.length > 0) { @@ -2208,9 +2354,19 @@ class MinimizerPlugin { options: this.options.minimizer.options, }); - // The salt is the name this plugin shipped under, and every `[contenthash]` - // is taken over it: renaming it would rename every file a user serves. hooks.chunkHash.tap(pluginName, (chunk, hash) => { + const willBe = chunkAssetName(compilation, chunk); + + // A chunk this instance was never pointed at cannot vary with its + // minimizers, so salting it would rename a file it never rewrites. A + // `filter` is not asked: it reads an asset's info, which no asset has + // yet, and guessing one could skip the salt for an asset it then takes. + if (typeof willBe === "string" && !this.matchesName(compiler, willBe)) { + return; + } + + // The salt is the name this plugin shipped under, and every + // `[fullhash]` is taken over it: renaming it would rename every file. hash.update("TerserPlugin"); hash.update(data); }); @@ -2333,8 +2489,9 @@ class MinimizerPlugin { for (const [at, generators] of generatorsByStage) { compilation.hooks.processAssets.tapPromise( - { name: pluginName, stage: at }, - () => this.generateAssets(compiler, compilation, generators), + { name: pluginName, stage: at, additionalAssets: true }, + (assets) => + this.generateAssets(compiler, compilation, generators, assets), ); } diff --git a/src/options.json b/src/options.json index c91fd78b..dc8104f0 100644 --- a/src/options.json +++ b/src/options.json @@ -288,6 +288,28 @@ "deleteOriginalAssets": { "description": "Removes the asset generated from. `asset` generators only.", "type": "boolean" + }, + "threshold": { + "description": "Generate only from assets larger than this, in bytes. `asset` generators only.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the generated asset only when it is this much smaller than the one it was read from, as `generated size / original size`. `asset` generators only.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the generated asset is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. `asset` generators only.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] } }, "required": ["implementation"] @@ -336,6 +358,28 @@ "deleteOriginalAssets": { "description": "Removes the asset generated from. `asset` generators only.", "type": "boolean" + }, + "threshold": { + "description": "Generate only from assets larger than this, in bytes. `asset` generators only.", + "type": "number", + "minimum": 0 + }, + "minRatio": { + "description": "Keep the generated asset only when it is this much smaller than the one it was read from, as `generated size / original size`. `asset` generators only.", + "type": "number", + "exclusiveMinimum": 0 + }, + "relatedName": { + "description": "The key the generated asset is recorded under in the original's `related` info, which is how a server finds it. `false` records nothing. `asset` generators only.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "enum": [false] + } + ] } }, "required": ["implementation"] diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index 34e35a9a..f191d6cc 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -715,7 +715,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; @@ -766,7 +766,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index 84d09f98..318e5f24 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -241,3 +241,17 @@ exports[`validation validate 19`] = ` * options.minimizerOptions should be an array: [object { … }, ...] (should not have fewer than 1 item)" `; + +exports[`validation validate 20`] = ` +"Invalid options object. Minimizer Plugin has been initialized using an options object that does not match the API schema. + - options.minify should be one of these: + function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. + -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number + Details: + * options.minify should be an instance of function. + * options.minify should be an array: + [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) + * options.minify should be an object: + object { implementation, options?, filter? }" +`; diff --git a/test/generate-option.test.js b/test/generate-option.test.js index 9e2668ff..2997b700 100644 --- a/test/generate-option.test.js +++ b/test/generate-option.test.js @@ -1287,6 +1287,27 @@ describe("generate options", () => { ).toThrow(/`filter` and `deleteOriginalAssets` in `generate`'s 'webp'/); }); + it("should reject the compression fields on an `import` generator", () => { + const webp = encoderNamed("WEBP"); + + // They describe a second file being worth writing, and an `import` + // generator writes none — it re-encodes the module it was asked for. + expect(() => + construct({ + generate: { + webp: { + implementation: webp, + threshold: 1024, + minRatio: 0.8, + relatedName: "gzipped", + }, + }, + }), + ).toThrow( + /`threshold` and `minRatio` and `relatedName` in `generate`'s 'webp'/, + ); + }); + it("should reject options given in both places for one generator", () => { const webp = encoderNamed("WEBP"); @@ -1786,6 +1807,885 @@ describe("replaceExtension", () => { }); }); +describe("what a generated file promises about its name", () => { + /** + * @param {string} filename the generator's filename template + * @returns {Promise} what the generated file says + */ + const infoFor = async (filename) => { + const copy = (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }); + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[contenthash].js", + }, + module: { + rules: [ + { + test: /\.(png|jpe?g|svg|webp)/i, + type: "asset/resource", + generator: { filename: "[name].[contenthash][ext]" }, + }, + ], + }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + generate: { implementation: copy, type: "asset", filename }, + }).apply(compiler); + + const stats = await compile(compiler); + const generated = Object.keys(stats.compilation.assets).find((name) => + name.includes(".copy"), + ); + + return stats.compilation.getAsset(generated).info; + }; + + it("should stay immutable where the name still carries the original's", async () => { + const info = await infoFor("[path][name].copy[ext]"); + + expect(info.immutable).toBe(true); + }); + + it("should not claim immutable where the name does not", async () => { + const info = await infoFor("fixed.copy.png"); + + // The original's promise rested on a hash in its name; a fixed name + // carries none, so the file behind it can change. + expect(info.immutable).toBeUndefined(); + }); +}); + +describe("generate beside the minifier", () => { + /** + * @returns {EXPECTED_ANY} a generator that hands back what it read + */ + const copier = () => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the same bytes + */ + const copy = (input) => { + const [[name, code]] = Object.entries(input); + + copy.saw.push(name); + + return { code: Buffer.isBuffer(code) ? code : Buffer.from(code) }; + }; + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + copy.saw = []; + + return copy; + }; + + it("should read the `.js` default where no `test` was set", async () => { + const copy = copier(); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // The default is the plugin's, not the minifier's, so a generator written + // for images is given a `test` that names them. + expect(copy.saw).toEqual(["main.js"]); + expect(getErrors(stats)).toEqual([]); + }); + + it("should still honour a `test` that is set", async () => { + const copy = copier(); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(copy.saw).toEqual(["image.png"]); + expect(getErrors(stats)).toEqual([]); + }); + + it("should minify as well as generate, and mark what it minified", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /\.(png|js)$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Both jobs run: the default minifier over the bundle, the generator over + // what `test` named — and `terserMinify` declines the image itself. + expect(readAsset("main.js", compiler, stats)).not.toContain("\n"); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ).info.minimized, + ).toBe(true); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.png") + ).info.minimized, + ).toBeUndefined(); + expect(Object.keys(stats.compilation.assets)).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should not rename a bundle no minimizer of its own would touch", async () => { + /** + * @param {boolean} withPlugin whether to apply the plugin + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (withPlugin) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + if (withPlugin) { + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // `test` names images, so no minimizer here is ever handed the bundle: + // salting its hash would rename a file this instance never rewrites. + expect(await namesFrom(true)).toEqual(await namesFrom(false)); + }); + + it("should still rename when a minimizer would be handed the bundle", async () => { + /** + * @param {EXPECTED_ANY} minimizerOptions what to run terser with + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (minimizerOptions) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + minimizerOptions, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // The guard above must not cost the salt its job: what terser is run with + // still varies the name of what it rewrote. + expect(await namesFrom({ mangle: true })).not.toEqual( + await namesFrom({ mangle: false }), + ); + }); + + it("should salt where a function names the file, which cannot be read ahead", async () => { + /** + * @param {boolean} withPlugin whether to apply the plugin + * @returns {Promise} the emitted JavaScript names + */ + const namesFrom = async (withPlugin) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + output: { + path: path.resolve(__dirname, "./dist"), + filename: () => "[name].[fullhash].js", + }, + module: { rules: IMAGE_RULES }, + }); + + if (withPlugin) { + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + } + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets) + .filter((name) => name.endsWith(".js")) + .sort(); + }; + + // Nothing can be read off a function before it is called, so the salt + // stands rather than being skipped on a guess. + expect(await namesFrom(true)).not.toEqual(await namesFrom(false)); + }); + + it("should still minify when only a generator was configured", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Configuring a generator does not turn the minifier off. + expect(readAsset("main.js", compiler, stats)).not.toContain("\n"); + expect( + /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("main.js") + ).info.minimized, + ).toBe(true); + expect(getErrors(stats)).toEqual([]); + }); +}); + +describe("generate over a file that is already there", () => { + it("should record `related` and delete the original even when the file exists", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, before it runs. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + expect(getErrors(stats)).toEqual([]); + // Written over rather than emitted, and the original still gone with it. + expect(names).toContain("image.copy.png"); + expect(names).not.toContain("image.png"); + expect(readAsset("image.copy.png", compiler, stats)).not.toBe("stale"); + }); + + it("should not keep what the name it wrote over promised", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, and promises for it. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + { immutable: true, sourceFilename: "somewhere/else.png" }, + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + const { info } = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.copy.png") + ); + + expect(getErrors(stats)).toEqual([]); + // What the generator says of the file it wrote, and nothing the name + // carried before it: webpack merges an info object into the old one. + expect(info.immutable).toBeUndefined(); + expect(info.sourceFilename).toBeUndefined(); + expect(info.generated).toBe(true); + }); + + it("should record `related` on the original where it is kept", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** Writes the name the generator is about to write, before it runs. */ + class AlreadyThere { + /** + * @param {import("webpack").Compiler} instance compiler + * @returns {void} + */ + apply(instance) { + instance.hooks.compilation.tap("AlreadyThere", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyThere", + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + "image.copy.png", + new compiler.webpack.sources.RawSource(Buffer.from("stale")), + ); + }, + ); + }); + } + } + + new AlreadyThere().apply(compiler); + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + }, + }).apply(compiler); + + const stats = await compile(compiler); + const original = /** @type {import("webpack").Asset} */ ( + stats.compilation.getAsset("image.png") + ); + + expect(getErrors(stats)).toEqual([]); + expect( + /** @type {{ [key: string]: string }} */ (original.info.related).copied, + ).toBe("image.copy.png"); + }); +}); + +describe("deleting the asset a file was written beside", () => { + it("should keep the generated file when `relatedName` is set too", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + // Deleting an asset takes everything its `related` names with it, so the + // two together must not delete the file that was just written. + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.copy.png"); + expect(names).not.toContain("image.png"); + }); + + it("should not mind a second generator having deleted it already", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + /** + * @param {string} suffix what to name what it writes + * @returns {EXPECTED_ANY} one generator + */ + const copyTo = (suffix) => ({ + implementation: (input) => ({ + code: Buffer.from(Object.values(input)[0]), + }), + type: "asset", + filename: `[path][name].${suffix}[ext]`, + deleteOriginalAssets: true, + }); + + new MinimizerPlugin({ + test: /^image\.png$/i, + generate: { one: copyTo("one"), two: copyTo("two") }, + }).apply(compiler); + + const stats = await compile(compiler); + const names = Object.keys(stats.compilation.assets); + + // Both wrote, and whichever deleted second found nothing left to delete. + expect(getErrors(stats)).toEqual([]); + expect(names).toContain("image.one.png"); + expect(names).toContain("image.two.png"); + expect(names).not.toContain("image.png"); + }); + + it("should keep a file written under the original's own name", async () => { + const compiler = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + + new MinimizerPlugin({ + parallel: false, + test: /\.js$/i, + generate: { + implementation: (input) => ({ + code: `/* generated */${Object.values(input)[0]}`, + }), + type: "asset", + filename: "[path][base]", + deleteOriginalAssets: true, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Re-encoding a file in place names it what it was called, so there is no + // original left beside it to delete — only the file just written. + expect(getErrors(stats)).toEqual([]); + expect(Object.keys(stats.compilation.assets)).toEqual(["one.js"]); + expect(readAsset("one.js", compiler, stats)).toMatch( + /^\/\* generated \*\//, + ); + }); +}); + +describe("generate from an asset emitted late", () => { + it("should generate from an asset added after the generators ran", async () => { + const seen = []; + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the same bytes + */ + const copy = (input) => { + const [[name, code]] = Object.entries(input); + + seen.push(name); + + return { code: Buffer.isBuffer(code) ? code : Buffer.from(code) }; + }; + + copy.supportsBinary = () => true; + copy.supportsWorker = () => false; + + class EmitLate { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + const { RawSource } = inner.webpack.sources; + + inner.hooks.compilation.tap("EmitLate", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "EmitLate", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + compilation.emitAsset("late.txt", new RawSource("late bytes")); + }, + ); + }); + } + } + + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new EmitLate().apply(compiler); + new MinimizerPlugin({ + test: /\.txt$/i, + generate: { + implementation: copy, + type: "asset", + filename: "[path][name].copy[ext]", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // The tap is re-invoked for what arrives after it first ran, so a file + // another plugin adds late still gets the one that belongs beside it. + expect(seen).toContain("late.txt"); + expect(Object.keys(stats.compilation.assets)).toContain("late.copy.txt"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should offer each asset once, and never a file of its own making", async () => { + const minified = []; + const generated = []; + + /** + * @param {string[]} seen where to record what it was handed + * @returns {(input: { [file: string]: string | Buffer }) => { code: string | Buffer }} the function + */ + const recording = (seen) => (input) => { + const [[name, code]] = Object.entries(input); + + seen.push(name); + + return { code }; + }; + + class EmitLate { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + const { RawSource } = inner.webpack.sources; + + inner.hooks.compilation.tap("EmitLate", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "EmitLate", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + if (!compilation.getAsset("late.js")) { + compilation.emitAsset( + "late.js", + new RawSource("var late = 1;"), + ); + } + }, + ); + }); + } + } + + const generate = recording(generated); + + generate.getStage = ( + /** @type {typeof import("webpack").Compilation} */ compilation, + ) => compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER; + + const compiler = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + + new EmitLate().apply(compiler); + new MinimizerPlugin({ + parallel: false, + test: /.*/, + minify: recording(minified), + generate: { + implementation: generate, + type: "asset", + filename: "[path][base].gz", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + // Each asset once to each pass, `late.js` included — and `one.js.gz`, which + // the generator wrote, to neither: minifying it is what it is not. + expect(minified).toEqual(["one.js", "late.js"]); + expect(generated).toEqual(["one.js", "late.js"]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "late.js", + "late.js.gz", + "one.js", + "one.js.gz", + ]); + expect(getErrors(stats)).toEqual([]); + }); +}); + +describe("generate assets, what is worth writing", () => { + /** + * A generator that pads or shrinks what it read, so `threshold` and + * `minRatio` can be driven from a known size. + * @param {number} factor how much of the input to hand back + * @returns {EXPECTED_ANY} the generator + */ + const scaleBy = (factor) => { + /** + * @param {{ [file: string]: string | Buffer }} input input + * @returns {{ code: Buffer }} the scaled result + */ + const scale = (input) => { + const [[, code]] = Object.entries(input); + const bytes = Buffer.isBuffer(code) ? code : Buffer.from(code); + + scale.calls += 1; + + return { + code: + factor <= 1 + ? bytes.subarray(0, Math.ceil(bytes.length * factor)) + : Buffer.concat([bytes, Buffer.alloc(bytes.length * (factor - 1))]), + }; + }; + + scale.supportsBinary = () => true; + scale.supportsWorker = () => false; + scale.calls = 0; + + return scale; + }; + + /** + * @param {object} descriptor extra generator descriptor keys + * @param {EXPECTED_ANY} implementation the generator + * @returns {Promise} what the build produced + */ + const build = async (descriptor, implementation) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation, + type: "asset", + filename: "[path][name].copy[ext]", + ...descriptor, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + return { compiler, stats, assets: Object.keys(stats.compilation.assets) }; + }; + + it("should skip an asset smaller than `threshold`", async () => { + const scale = scaleBy(1); + const { stats, assets } = await build({ threshold: 1024 * 1024 }, scale); + + // Nothing is even read: the size is known before the generator runs. + expect(scale.calls).toBe(0); + expect(assets).not.toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should generate from an asset larger than `threshold`", async () => { + const scale = scaleBy(1); + const { stats, assets } = await build({ threshold: 1024 }, scale); + + expect(scale.calls).toBe(1); + expect(assets).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should drop a result that is not `minRatio` smaller", async () => { + const scale = scaleBy(2); + const { stats, assets } = await build({ minRatio: 0.8 }, scale); + + // It ran and its answer was twice the size, so keeping it would cost a + // request to serve more bytes than the file it came from. + expect(scale.calls).toBe(1); + expect(assets).not.toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should keep a result that is `minRatio` smaller", async () => { + const scale = scaleBy(0.5); + const { stats, assets } = await build({ minRatio: 0.8 }, scale); + + expect(assets).toContain("image.copy.png"); + expect(getErrors(stats)).toEqual([]); + }); + + it("should record the generated asset under `relatedName`", async () => { + const { stats } = await build({ relatedName: "copied" }, scaleBy(1)); + + // The original points at it, which is how a server asked for the original + // finds the file beside it. + expect(stats.compilation.getAsset("image.png").info.related.copied).toBe( + "image.copy.png", + ); + expect(getErrors(stats)).toEqual([]); + }); + + it("should leave an asset already carrying that key alone", async () => { + const scale = scaleBy(1); + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/images.js"), + module: { rules: IMAGE_RULES }, + }); + + class AlreadyCopied { + /** + * @param {import("webpack").Compiler} inner compiler + * @returns {void} + */ + apply(inner) { + inner.hooks.compilation.tap("AlreadyCopied", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "AlreadyCopied", + stage: compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + (assets) => { + for (const name of Object.keys(assets)) { + if (/\.png$/i.test(name)) { + compilation.updateAsset(name, (one) => one, { + related: { copied: "elsewhere.png" }, + }); + } + } + }, + ); + }); + } + } + + new AlreadyCopied().apply(compiler); + new MinimizerPlugin({ + test: /\.png$/i, + generate: { + implementation: scale, + type: "asset", + filename: "[path][name].copy[ext]", + relatedName: "copied", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(scale.calls).toBe(0); + expect(getErrors(stats)).toEqual([]); + }); +}); + describe("generate assets, byte for byte", () => { const PREFIX = "/* prepended */"; diff --git a/test/stage-option.test.js b/test/stage-option.test.js index f0468690..3a81bad7 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -56,6 +56,52 @@ const lateGenerator = (order, label) => { return run; }; +/** + * @param {string[]} order where to record + * @param {string} label what to record + * @param {number=} stage the stage it asks for, if any + * @returns {EXPECTED_ANY} the minimizer + */ +const asking = (order, label, stage) => { + /** + * @param {Record} input input + * @returns {{ code: string | Buffer }} the result + */ + const run = (input) => { + order.push(label); + + return { code: Object.values(input)[0] }; + }; + + if (typeof stage === "number") { + run.getStage = () => stage; + } + + return run; +}; + +/** + * The stages this plugin taps `processAssets` in, filled as the compilation + * starts rather than when this is called. Reads whatever was applied before + * it, so it is called after the plugin under test. + * @param {import("webpack").Compiler} own compiler + * @returns {number[]} the stages, in the order the hook runs them + */ +const tappedStages = (own) => { + /** @type {number[]} */ + const stages = []; + + own.hooks.compilation.tap("ReadTaps", (compilation) => { + for (const tap of compilation.hooks.processAssets.taps) { + if (tap.name === "MinimizerPlugin") { + stages.push(/** @type {number} */ (tap.stage)); + } + } + }); + + return stages; +}; + class RecordStage { constructor(order, label, stage) { this.order = order; @@ -180,6 +226,127 @@ describe("where work runs", () => { expect(getErrors(stats)).toEqual([]); expect(getWarnings(stats)).toEqual([]); }); + + it("should tap once for every stage asked for, rather than once for all", async () => { + const order = []; + + new MinimizerPlugin({ + parallel: false, + minify: [ + asking(order, "minify"), + asking( + order, + "transfer", + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + ), + asking(order, "summarize", Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE), + ], + generate: { + inline: { + implementation: asking( + order, + "inline", + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + ), + type: "asset", + filename: "[path][base].inline", + }, + report: { + implementation: asking( + order, + "report", + Compilation.PROCESS_ASSETS_STAGE_REPORT, + ), + type: "asset", + filename: "[path][base].report", + }, + }, + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + // Five of them written in neither this order nor one another's, so the + // hook holds one tap per stage and runs them where each asked to be. + expect(stages).toEqual([ + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, + Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER, + Compilation.PROCESS_ASSETS_STAGE_REPORT, + ]); + expect(order).toEqual([ + "minify", + "inline", + "summarize", + "transfer", + "report", + ]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "one.js", + "one.js.inline", + "one.js.report", + ]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); + + it("should tap once where nothing asks for a stage of its own", async () => { + const order = []; + + new MinimizerPlugin({ + parallel: false, + minify: [asking(order, "first"), asking(order, "second")], + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + expect(stages).toEqual([Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE]); + expect(order).toEqual(["first", "second"]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); + + it("should share one tap between everything asking for the same stage", async () => { + const order = []; + const transfer = Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER; + + new MinimizerPlugin({ + parallel: false, + minify: [ + asking(order, "first", transfer), + asking(order, "second", transfer), + ], + generate: { + a: { + implementation: asking(order, "a", transfer), + type: "asset", + filename: "[path][base].a", + }, + b: { + implementation: asking(order, "b", transfer), + type: "asset", + filename: "[path][base].b", + }, + }, + }).apply(compiler); + + const stages = tappedStages(compiler); + const stats = await compile(compiler); + + // Two taps for four: one for the minimizers and one for the generators, + // which cannot share it — a generator reads what a minimizer wrote. + expect(stages).toEqual([transfer, transfer]); + expect(order).toEqual(["first", "second", "a", "b"]); + expect(Object.keys(stats.compilation.assets).sort()).toEqual([ + "one.js", + "one.js.a", + "one.js.b", + ]); + expect(getErrors(stats)).toEqual([]); + expect(getWarnings(stats)).toEqual([]); + }); }); describe("a minimizer that asks for its own stage", () => { @@ -191,30 +358,6 @@ describe("a minimizer that asks for its own stage", () => { }); }); - /** - * @param {string[]} order where to record - * @param {string} label what to record - * @param {number=} stage the stage it asks for, if any - * @returns {EXPECTED_ANY} the minimizer - */ - const asking = (order, label, stage) => { - /** - * @param {Record} input input - * @returns {{ code: string | Buffer }} the result - */ - const run = (input) => { - order.push(label); - - return { code: Object.values(input)[0] }; - }; - - if (typeof stage === "number") { - run.getStage = () => stage; - } - - return run; - }; - it("should run where `getStage` asks, with no option given", async () => { const order = []; diff --git a/test/validate-options.test.js b/test/validate-options.test.js index 22f264ea..3f6bf8f2 100644 --- a/test/validate-options.test.js +++ b/test/validate-options.test.js @@ -342,6 +342,10 @@ describe("validation", () => { terserOptions: { ecma: 5 }, }); }).not.toThrow(); + + expect(() => { + createCompiler({ minify: false }); + }).toThrowErrorMatchingSnapshot(); }); it("should validate a minimizer added through `optimization.minimizer`", () => { diff --git a/types/index.d.ts b/types/index.d.ts index 21f93ec3..99238f56 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -96,7 +96,7 @@ declare class MinimizerPlugin { * @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 + * @returns {{ name: string | undefined, implementation: EXPECTED_ANY, options: EXPECTED_ANY, type: string | undefined, filename: string | undefined, filter: ((name: string) => boolean) | undefined, deleteOriginalAssets: boolean | undefined, threshold: number | undefined, minRatio: number | undefined, relatedName: string | false | undefined }} the generator */ private describeGenerator; /** @@ -131,6 +131,13 @@ declare class MinimizerPlugin { * @returns {Set} the names */ private assetFlags; + /** + * Every name this plugin's `asset` generators mark what they wrote with, + * which is how both passes tell a generated file from one to work on. + * @private + * @returns {string[]} the names + */ + private generatedFlags; /** * The generators that run over emitted assets rather than over a module as * it builds. @@ -167,6 +174,7 @@ declare class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {ReturnType} generators the generators running at this stage + * @param {Record} assets the assets this pass was handed * @returns {Promise} */ private generateAssets; @@ -304,6 +312,8 @@ declare namespace MinimizerPlugin { InternalOptions, MinimizerWorker, Parallel, + GeneratorDescriptor, + Generate, BasePluginOptions, DefinedDefaultMinimizerAndOptions, InternalPluginOptions, @@ -629,6 +639,61 @@ type MinimizerWorker = JestWorker & { minify: (options: InternalOptions) => Promise; }; type Parallel = undefined | boolean | number; +/** + * One generator, written as an object stating how to run it. + */ +type GeneratorDescriptor = { + /** + * the generator itself + */ + implementation: MinimizerImplementation; + /** + * options for this generator, preferred over the deprecated `generatorOptions` + */ + options?: MinimizerOptions | undefined; + /** + * `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted + */ + type?: ("import" | "asset") | undefined; + /** + * name for the generated asset, as a webpack filename template. `asset` generators only + */ + filename?: string | undefined; + /** + * decides per asset whether to generate from it, on top of `test`/`include`/`exclude` + */ + filter?: ((name: string) => boolean) | undefined; + /** + * removes the asset generated from. `asset` generators only + */ + deleteOriginalAssets?: boolean | undefined; + /** + * generate only from assets larger than this, in bytes. `asset` generators only + */ + threshold?: number | undefined; + /** + * keep the generated asset only when it is this much smaller than the one it was read from. `asset` generators only + */ + minRatio?: number | undefined; + /** + * the key the generated asset is recorded under in the original's `related` info. `asset` generators only + */ + relatedName?: (string | false) | undefined; +}; +/** + * What `generate` may be written as: one generator, a list of them, a + * descriptor, or an object naming descriptors an asset asks for with `?as=`. + */ +type Generate = + | MinimizerImplementation + | MinimizerImplementation[] + | GeneratorDescriptor + | { + [preset: string]: + | MinimizerImplementation + | MinimizerImplementation[] + | GeneratorDescriptor; + }; type BasePluginOptions = { /** * test rule @@ -651,9 +716,9 @@ type BasePluginOptions = { */ parallel?: Parallel | undefined; /** - * rewrites a module's own bytes as it is built, so a re-encoding can rename the asset + * rewrites a module's own bytes as it is built, so a re-encoding can rename the asset, or writes a new file beside one already emitted */ - generate?: MinimizerImplementation | undefined; + generate?: Generate | undefined; /** * options for `generate` */