From ee4b24135e8b31834c475015ba695d0da86b74f2 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:34:10 +0000 Subject: [PATCH 1/5] feat: let a minify function format the extractComments banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `formatBanner` helper on the minify function replaces the `/*! … */` wrapper, so an HTML minimizer can write ``: a `/*!` line is text in a document, and before the doctype it switches to quirks mode. --- .changeset/format-banner.md | 5 ++++ README.md | 6 ++++- src/index.js | 7 ++++- test/__snapshots__/minify-option.test.js.snap | 4 +++ test/minify-option.test.js | 27 +++++++++++++++++++ types/index.d.ts | 4 +++ 6 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .changeset/format-banner.md diff --git a/.changeset/format-banner.md b/.changeset/format-banner.md new file mode 100644 index 00000000..921a8f42 --- /dev/null +++ b/.changeset/format-banner.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Add a `formatBanner` helper to a `minify` function, so an HTML minimizer can write the `extractComments` banner as ``. diff --git a/README.md b/README.md index beedd214..45ca0945 100644 --- a/README.md +++ b/README.md @@ -1410,7 +1410,11 @@ The banner text that points to the extracted file and will be added at the top o It can be `false` (no banner), a `String`, or a `function<(string) -> String>` that will be called with the filename where the extracted comments have been stored. -The banner will be wrapped in a comment. +The banner will be wrapped in a comment: `/*! … */` by default, or whatever the `minify` function's `formatBanner` helper returns. An HTML minimizer needs one, since a `/*!` line is text in a document: + +```js +minify.formatBanner = (banner) => ``; +``` **webpack.config.js** diff --git a/src/index.js b/src/index.js index 01bb0ea9..4046ff1d 100644 --- a/src/index.js +++ b/src/index.js @@ -198,6 +198,7 @@ const canUseWorkerThreads = () => { * @property {() => string[] | undefined=} getTypes the languages this minimizer minifies, e.g. `["css"]`. Source that carries no filename — what a module embeds in another language's output — is dispatched by this rather than by `test` / `filter`, and a minimizer that declares nothing is never handed any * @property {(minimizerOptions?: EXPECTED_OBJECT) => string[] | undefined=} getEmbeddedTypes the languages this minimizer can hand out from inside what it minifies, through the `renderEmbeddedSource` option. Empty (or absent) means it nests nothing a caller can reach, and the option is not passed * @property {(compilation: typeof import("webpack").Compilation) => number | undefined=} getStage which `processAssets` stage this minimizer has to run in, named off the `Compilation` it is handed — compressing reads the bytes a user downloads, so it asks for `PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER`. Each runs where it asks, chaining through the asset a later pass reads back, and one asking for nothing runs where minifying belongs — after the bundle is rendered and before its hashes are taken + * @property {(banner: string) => string=} formatBanner the comment the `extractComments` banner is written as at the top of the asset, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document * @property {() => string | undefined=} getAssetFlag the name this function's work goes under in the asset's info, which is what the asset it wrote is marked with and what stats print. `compress` says `compressed`, another encoding of the bytes being no smaller a version of them; a minimizer saying nothing minified the asset, so `minimized`, and a generator saying nothing wrote a new file, so `generated`. It is also what is not run twice: an asset already marked with every name a function writes is declined, which is how a minified asset a child compilation handed up is left alone */ @@ -1179,9 +1180,13 @@ class MinimizerPlugin { } if (banner) { + const formatter = matched + .map((i) => minimizerSlots[i].fn.formatBanner) + .find((format) => typeof format === "function"); + output.source = new ConcatSource( shebang ? `${shebang}\n` : "", - `/*! ${banner} */\n`, + `${formatter ? formatter(String(banner)) : `/*! ${banner} */`}\n`, output.source, ); } diff --git a/test/__snapshots__/minify-option.test.js.snap b/test/__snapshots__/minify-option.test.js.snap index 8ac4d4a5..689a835b 100644 --- a/test/__snapshots__/minify-option.test.js.snap +++ b/test/__snapshots__/minify-option.test.js.snap @@ -1066,3 +1066,7 @@ exports[`minify option should work: assets 1`] = ` exports[`minify option should work: errors 1`] = `[]`; exports[`minify option should work: warnings 1`] = `[]`; + +exports[`minify option should write the banner as the minimizer's \`formatBanner\` says: errors 1`] = `[]`; + +exports[`minify option should write the banner as the minimizer's \`formatBanner\` says: warnings 1`] = `[]`; diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 147f251d..e0ddd2c4 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -33,6 +33,7 @@ import { getCompiler, getErrors, getWarnings, + readAsset, readsAssets, } from "./helpers"; @@ -229,6 +230,32 @@ describe("minify option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should write the banner as the minimizer's `formatBanner` says", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + }); + + const minify = async (input) => ({ + code: `
${Object.keys(input)[0]}
`, + extractedComments: [""], + }); + + minify.formatBanner = (banner) => ``; + + new MinimizerPlugin({ parallel: false, minify }).apply(compiler); + + const stats = await compile(compiler); + + expect(readAsset("main.js", compiler, stats)).toBe( + "\nmain.js
", + ); + expect(readAsset("main.js.LICENSE.txt", compiler, stats)).toBe( + "\n", + ); + expect(getErrors(stats)).toMatchSnapshot("errors"); + expect(getWarnings(stats)).toMatchSnapshot("warnings"); + }); + it("should work with source maps", async () => { const compiler = getCompiler({ devtool: "source-map", diff --git a/types/index.d.ts b/types/index.d.ts index a5b95f0a..3a74c9c8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -581,6 +581,10 @@ type MinimizeFunctionHelpers = { compilation: typeof import("webpack").Compilation, ) => number | undefined) | undefined; + /** + * the comment the `extractComments` banner is written as at the top of the asset, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document + */ + formatBanner?: ((banner: string) => string) | undefined; /** * the name this function's work goes under in the asset's info, which is what the asset it wrote is marked with and what stats print. `compress` says `compressed`, another encoding of the bytes being no smaller a version of them; a minimizer saying nothing minified the asset, so `minimized`, and a generator saying nothing wrote a new file, so `generated`. It is also what is not run twice: an asset already marked with every name a function writes is declined, which is how a minified asset a child compilation handed up is left alone */ From 3c2f491c006e9a62204ad7029a44e20211174f96 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:50:03 +0000 Subject: [PATCH 2/5] feat: let a minify function put the extractComments banner at the end A `getBannerPosition` helper returning `"end"` appends the banner instead of prepending it, so an HTML document keeps its doctype first. --- .changeset/format-banner.md | 2 +- README.md | 6 ++++ src/index.js | 29 ++++++++++++++----- test/__snapshots__/minify-option.test.js.snap | 4 +++ test/minify-option.test.js | 24 +++++++++++++++ types/index.d.ts | 6 +++- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/.changeset/format-banner.md b/.changeset/format-banner.md index 921a8f42..01ff0258 100644 --- a/.changeset/format-banner.md +++ b/.changeset/format-banner.md @@ -2,4 +2,4 @@ "minimizer-webpack-plugin": minor --- -Add a `formatBanner` helper to a `minify` function, so an HTML minimizer can write the `extractComments` banner as ``. +Add `formatBanner` and `getBannerPosition` helpers to a `minify` function, so an HTML minimizer can write the `extractComments` banner as `` at the end. diff --git a/README.md b/README.md index 45ca0945..3c5694df 100644 --- a/README.md +++ b/README.md @@ -1416,6 +1416,12 @@ The banner will be wrapped in a comment: `/*! … */` by default, or whatever th minify.formatBanner = (banner) => ``; ``` +The banner goes at the top of the asset; a `getBannerPosition` helper returning `"end"` appends it instead, which keeps a document's doctype first: + +```js +minify.getBannerPosition = () => "end"; +``` + **webpack.config.js** ```js diff --git a/src/index.js b/src/index.js index 4046ff1d..f6a17575 100644 --- a/src/index.js +++ b/src/index.js @@ -198,7 +198,8 @@ const canUseWorkerThreads = () => { * @property {() => string[] | undefined=} getTypes the languages this minimizer minifies, e.g. `["css"]`. Source that carries no filename — what a module embeds in another language's output — is dispatched by this rather than by `test` / `filter`, and a minimizer that declares nothing is never handed any * @property {(minimizerOptions?: EXPECTED_OBJECT) => string[] | undefined=} getEmbeddedTypes the languages this minimizer can hand out from inside what it minifies, through the `renderEmbeddedSource` option. Empty (or absent) means it nests nothing a caller can reach, and the option is not passed * @property {(compilation: typeof import("webpack").Compilation) => number | undefined=} getStage which `processAssets` stage this minimizer has to run in, named off the `Compilation` it is handed — compressing reads the bytes a user downloads, so it asks for `PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER`. Each runs where it asks, chaining through the asset a later pass reads back, and one asking for nothing runs where minifying belongs — after the bundle is rendered and before its hashes are taken - * @property {(banner: string) => string=} formatBanner the comment the `extractComments` banner is written as at the top of the asset, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document + * @property {(banner: string) => string=} formatBanner the comment the `extractComments` banner is written as, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document + * @property {() => "start" | "end" | undefined=} getBannerPosition where the `extractComments` banner goes, `"start"` when absent. `"end"` appends it with nothing between, which an HTML minimizer asks for so that the doctype stays the first thing a document holds * @property {() => string | undefined=} getAssetFlag the name this function's work goes under in the asset's info, which is what the asset it wrote is marked with and what stats print. `compress` says `compressed`, another encoding of the bytes being no smaller a version of them; a minimizer saying nothing minified the asset, so `minimized`, and a generator saying nothing wrote a new file, so `generated`. It is also what is not run twice: an asset already marked with every name a function writes is declined, which is how a minified asset a child compilation handed up is left alone */ @@ -1180,15 +1181,27 @@ class MinimizerPlugin { } if (banner) { - const formatter = matched - .map((i) => minimizerSlots[i].fn.formatBanner) + const helpers = matched.map((i) => minimizerSlots[i].fn); + const formatter = helpers + .map((fn) => fn.formatBanner) .find((format) => typeof format === "function"); - - output.source = new ConcatSource( - shebang ? `${shebang}\n` : "", - `${formatter ? formatter(String(banner)) : `/*! ${banner} */`}\n`, - output.source, + const positioned = helpers.find( + (fn) => typeof fn.getBannerPosition === "function", ); + const comment = formatter + ? formatter(String(banner)) + : `/*! ${banner} */`; + + output.source = + positioned && + /** @type {() => "start" | "end" | undefined} */ + (positioned.getBannerPosition)() === "end" + ? new ConcatSource(output.source, comment) + : new ConcatSource( + shebang ? `${shebang}\n` : "", + `${comment}\n`, + output.source, + ); } } diff --git a/test/__snapshots__/minify-option.test.js.snap b/test/__snapshots__/minify-option.test.js.snap index 689a835b..c0507516 100644 --- a/test/__snapshots__/minify-option.test.js.snap +++ b/test/__snapshots__/minify-option.test.js.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`minify option should append the banner where the minimizer's \`getBannerPosition\` says the end: errors 1`] = `[]`; + +exports[`minify option should append the banner where the minimizer's \`getBannerPosition\` says the end: warnings 1`] = `[]`; + exports[`minify option should carry the last good code forward when a step in the array returns no code: assets 1`] = ` { "main.js": "(()=>{"use strict";console.log("HERE")})(); diff --git a/test/minify-option.test.js b/test/minify-option.test.js index e0ddd2c4..daf34aaa 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -256,6 +256,30 @@ describe("minify option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should append the banner where the minimizer's `getBannerPosition` says the end", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + }); + + const minify = async (input) => ({ + code: `${Object.keys(input)[0]}
`, + extractedComments: [""], + }); + + minify.formatBanner = (banner) => ``; + minify.getBannerPosition = () => "end"; + + new MinimizerPlugin({ parallel: false, minify }).apply(compiler); + + const stats = await compile(compiler); + + expect(readAsset("main.js", compiler, stats)).toBe( + "main.js
", + ); + expect(getErrors(stats)).toMatchSnapshot("errors"); + expect(getWarnings(stats)).toMatchSnapshot("warnings"); + }); + it("should work with source maps", async () => { const compiler = getCompiler({ devtool: "source-map", diff --git a/types/index.d.ts b/types/index.d.ts index 3a74c9c8..093a19c9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -582,9 +582,13 @@ type MinimizeFunctionHelpers = { ) => number | undefined) | undefined; /** - * the comment the `extractComments` banner is written as at the top of the asset, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document + * the comment the `extractComments` banner is written as, a `/*!` block comment when absent. An HTML minimizer writes ``, a block comment being text in a document */ formatBanner?: ((banner: string) => string) | undefined; + /** + * where the `extractComments` banner goes, `"start"` when absent. `"end"` appends it with nothing between, which an HTML minimizer asks for so that the doctype stays the first thing a document holds + */ + getBannerPosition?: (() => "start" | "end" | undefined) | undefined; /** * the name this function's work goes under in the asset's info, which is what the asset it wrote is marked with and what stats print. `compress` says `compressed`, another encoding of the bytes being no smaller a version of them; a minimizer saying nothing minified the asset, so `minimized`, and a generator saying nothing wrote a new file, so `generated`. It is also what is not run twice: an asset already marked with every name a function writes is declined, which is how a minified asset a child compilation handed up is left alone */ From 6419638bacad9012c866a464e225baa7821f48fc Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:04:38 +0000 Subject: [PATCH 3/5] chore(deps): bump fast-uri from 3.1.6 to 3.1.8 The same lockfile change as #750, so npm audit passes in Lint. --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86cc5929..0a672192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "minimizer-webpack-plugin", - "version": "5.10.1", + "version": "5.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "minimizer-webpack-plugin", - "version": "5.10.1", + "version": "5.12.0", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -11753,9 +11753,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", From cc63bde11aae1ed06168b3550f27e2b46a066b4d Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:21:08 +0000 Subject: [PATCH 4/5] fix: keep the shebang, report a throwing banner helper, and key the cache on it An end banner now keeps the shebang it was split from, a formatBanner or getBannerPosition that throws becomes an error of that asset rather than rejecting processAssets, and a declared helper's source joins the cache and chunk-hash identity. --- src/index.js | 53 ++++++++++++++++++-------- test/minify-option.test.js | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/src/index.js b/src/index.js index f6a17575..77012d34 100644 --- a/src/index.js +++ b/src/index.js @@ -1188,20 +1188,32 @@ class MinimizerPlugin { const positioned = helpers.find( (fn) => typeof fn.getBannerPosition === "function", ); - const comment = formatter - ? formatter(String(banner)) - : `/*! ${banner} */`; - - output.source = - positioned && - /** @type {() => "start" | "end" | undefined} */ - (positioned.getBannerPosition)() === "end" - ? new ConcatSource(output.source, comment) - : new ConcatSource( - shebang ? `${shebang}\n` : "", - `${comment}\n`, - output.source, - ); + + // The helpers are the minimizer's code, so one that throws is + // reported against this asset like a failed minify. + try { + const comment = formatter + ? formatter(String(banner)) + : `/*! ${banner} */`; + const head = shebang ? `${shebang}\n` : ""; + + output.source = + positioned && + /** @type {() => "start" | "end" | undefined} */ + (positioned.getBannerPosition)() === "end" + ? new ConcatSource(head, output.source, comment) + : new ConcatSource(head, `${comment}\n`, output.source); + } catch (error) { + compilation.errors.push( + MinimizerPlugin.buildError( + /** @type {Error | ErrorObject | string} */ + (error), + name, + ), + ); + + return; + } } } @@ -2482,8 +2494,17 @@ class MinimizerPlugin { ? fn.getMinimizerVersion() || "0.0.0" : "0.0.0"; + // A banner helper changes the bytes written, so its source joins the + // identity; only where one is declared, so no other key moves. + const banner = + fn && + (typeof fn.formatBanner === "function" || + typeof fn.getBannerPosition === "function") + ? `|${String(fn.formatBanner)}|${String(fn.getBannerPosition)}` + : ""; + if (!ref) { - return version; + return `${version}${banner}`; } // Which module it is, read against the build rather than the disk: two @@ -2496,7 +2517,7 @@ class MinimizerPlugin { ) .replace(/\\/g, "/"); - return `${version}|${where}|${ref.export || ""}`; + return `${version}|${where}|${ref.export || ""}${banner}`; }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) diff --git a/test/minify-option.test.js b/test/minify-option.test.js index daf34aaa..950807f8 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -280,6 +280,82 @@ describe("minify option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should keep the shebang first when the banner goes at the end", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + }); + + const minify = async () => ({ + code: "#!/usr/bin/env node\nrun()", + extractedComments: ["/*! @license MIT */"], + }); + + minify.formatBanner = (banner) => `/* ${banner} */`; + minify.getBannerPosition = () => "end"; + + new MinimizerPlugin({ parallel: false, minify }).apply(compiler); + + const stats = await compile(compiler); + + expect(readAsset("main.js", compiler, stats)).toBe( + "#!/usr/bin/env node\nrun()/* For license information please see main.js.LICENSE.txt */", + ); + }); + + it("should report a banner helper that throws as an error of the asset", async () => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + bail: false, + }); + + const minify = async () => ({ + code: "run()", + extractedComments: ["/*! @license MIT */"], + }); + + minify.formatBanner = () => { + throw new Error("no banner"); + }; + + new MinimizerPlugin({ parallel: false, minify }).apply(compiler); + + const stats = await compile(compiler); + + expect(stats.compilation.errors).toHaveLength(1); + expect(stats.compilation.errors[0].message).toMatch( + /main\.js from minimizer-webpack-plugin[\s\S]*no banner/, + ); + }); + + it("should name a chunk apart when only its minimizer's banner helper differs", async () => { + /** + * @param {(banner: string) => string} formatBanner the helper + * @returns {Promise