Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/format-banner.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `<!-- ${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**

Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 45 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- ${banner} -->`, 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
*/

Expand Down Expand Up @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;
}
}
}

Expand Down Expand Up @@ -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)}`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: "";

if (!ref) {
return version;
return `${version}${banner}`;
}

// Which module it is, read against the build rather than the disk: two
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions test/__snapshots__/minify-option.test.js.snap
Original file line number Diff line number Diff line change
@@ -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")})();
Expand Down Expand Up @@ -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`] = `[]`;
127 changes: 127 additions & 0 deletions test/minify-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
getCompiler,
getErrors,
getWarnings,
readAsset,
readsAssets,
} from "./helpers";

Expand Down Expand Up @@ -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: `<p>${Object.keys(input)[0]}</p>`,
extractedComments: ["<!-- @license MIT -->"],
});

minify.formatBanner = (banner) => `<!-- ${banner} -->`;

new MinimizerPlugin({ parallel: false, minify }).apply(compiler);

const stats = await compile(compiler);

expect(readAsset("main.js", compiler, stats)).toBe(
"<!-- For license information please see main.js.LICENSE.txt -->\n<p>main.js</p>",
);
expect(readAsset("main.js.LICENSE.txt", compiler, stats)).toBe(
"<!-- @license MIT -->\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: `<!doctype html><p>${Object.keys(input)[0]}</p>`,
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(
"<!doctype html><p>main.js</p><!-- For license information please see main.js.LICENSE.txt -->",
);
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<string[]>} 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) => `<!-- ${banner} -->`),
);
});

it("should work with source maps", async () => {
const compiler = getCompiler({
devtool: "source-map",
Expand Down
8 changes: 8 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- ${banner} -->`, 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
*/
Expand Down
Loading