diff --git a/.changeset/format-banner.md b/.changeset/format-banner.md new file mode 100644 index 00000000..01ff0258 --- /dev/null +++ b/.changeset/format-banner.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +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 beedd214..5bfbec74 100644 --- a/README.md +++ b/README.md @@ -1410,7 +1410,19 @@ 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) => ``; +``` + +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"; +``` + +The source of both helpers is part of the cache key and the `[chunkhash]`, but not the values they close over: a helper whose output depends on such a value must make `getMinimizerVersion` change with it, or a rebuild reuses the earlier banner from the cache. **webpack.config.js** 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", diff --git a/src/index.js b/src/index.js index 01bb0ea9..77012d34 100644 --- a/src/index.js +++ b/src/index.js @@ -198,6 +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, 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 */ @@ -1179,11 +1181,39 @@ class MinimizerPlugin { } if (banner) { - output.source = new ConcatSource( - shebang ? `${shebang}\n` : "", - `/*! ${banner} */\n`, - output.source, + const helpers = matched.map((i) => minimizerSlots[i].fn); + const formatter = helpers + .map((fn) => fn.formatBanner) + .find((format) => typeof format === "function"); + const positioned = helpers.find( + (fn) => typeof fn.getBannerPosition === "function", ); + + // 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; + } } } @@ -2464,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 @@ -2478,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/__snapshots__/minify-option.test.js.snap b/test/__snapshots__/minify-option.test.js.snap index 8ac4d4a5..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")})(); @@ -1066,3 +1070,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..950807f8 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,132 @@ 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( + "\n

main.js

", + ); + expect(readAsset("main.js.LICENSE.txt", compiler, stats)).toBe( + "\n", + ); + expect(getErrors(stats)).toMatchSnapshot("errors"); + 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 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} the emitted script names + */ + const build = async (formatBanner) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + output: { filename: "[name].[chunkhash].js" }, + }); + + const minify = async () => ({ code: "run()" }); + + minify.formatBanner = formatBanner; + + new MinimizerPlugin({ parallel: false, minify }).apply(compiler); + + const stats = await compile(compiler); + + return Object.keys(stats.compilation.assets).filter((name) => + name.endsWith(".js"), + ); + }; + + expect(await build((banner) => `/* ${banner} */`)).not.toEqual( + await build((banner) => ``), + ); + }); + 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..093a19c9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -581,6 +581,14 @@ type MinimizeFunctionHelpers = { compilation: typeof import("webpack").Compilation, ) => number | undefined) | undefined; + /** + * 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 */