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/fragment-asset-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"minimizer-webpack-plugin": patch
---

Minify assets whose names carry a `#fragment`, such as `[name].js#[contenthash]` or an asset module named `[hash][ext][query][fragment]`.
26 changes: 19 additions & 7 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,9 @@ class MinimizerPlugin {
})
);
const test =
typeof declaredTest !== "undefined" ? declaredTest : /\.[cm]?js(\?.*)?$/i;
typeof declaredTest !== "undefined"
? declaredTest
: /^[^?#]*\.[cm]?js(?:[?#].*)?$/i;

// `terserOptions` is a deprecated alias of `minimizerOptions`; prefer the
// new name when both are provided.
Expand Down Expand Up @@ -987,9 +989,9 @@ class MinimizerPlugin {

if (typeof info.javascriptModule !== "undefined") {
options.module = info.javascriptModule;
} else if (/\.mjs(\?.*)?$/i.test(name)) {
} else if (/^[^?#]*\.mjs(?:[?#].*)?$/i.test(name)) {
options.module = true;
} else if (/\.cjs(\?.*)?$/i.test(name)) {
} else if (/^[^?#]*\.cjs(?:[?#].*)?$/i.test(name)) {
options.module = false;
}

Expand Down Expand Up @@ -1108,11 +1110,21 @@ class MinimizerPlugin {
let query = "";
let filename = name;

const querySplit = filename.indexOf("?");
// A fragment is no part of the file on disk, so neither `filename`,
// `query` nor a name built from them may carry one.
const suffixSplit = filename.search(/[?#]/);

if (querySplit >= 0) {
query = filename.slice(querySplit);
filename = filename.slice(0, querySplit);
if (suffixSplit >= 0) {
if (filename[suffixSplit] === "?") {
const fragmentSplit = filename.indexOf("#", suffixSplit);

query = filename.slice(
suffixSplit,
fragmentSplit >= 0 ? fragmentSplit : undefined,
);
}

filename = filename.slice(0, suffixSplit);
}

const lastSlashIndex = filename.lastIndexOf("/");
Expand Down
28 changes: 12 additions & 16 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,14 @@ function getMinimizerOptionsAt(minimizerOptions, index) {
: minimizerOptions;
}

const JS_FILE_RE = /\.[cm]?js(\?.*)?$/i;
const JSON_FILE_RE = /\.json(\?.*)?$/i;
const HTML_FILE_RE = /\.html?(\?.*)?$/i;
const CSS_FILE_RE = /\.css(\?.*)?$/i;
const SVG_FILE_RE = /\.svg(\?.*)?$/i;
const JS_FILE_RE = /^[^?#]*\.[cm]?js(?:[?#].*)?$/i;
const JSON_FILE_RE = /^[^?#]*\.json(?:[?#].*)?$/i;
const HTML_FILE_RE = /^[^?#]*\.html?(?:[?#].*)?$/i;
const CSS_FILE_RE = /^[^?#]*\.css(?:[?#].*)?$/i;
const SVG_FILE_RE = /^[^?#]*\.svg(?:[?#].*)?$/i;
// What `imageminMinify` is offered; its plugins decide what they act on.
const IMAGE_FILE_RE = /\.(?:avif|gif|jpe?g|jxl|png|svg|tiff?|webp)(\?.*)?$/i;
const IMAGE_FILE_RE =
/^[^?#]*\.(?:avif|gif|jpe?g|jxl|png|svg|tiff?|webp)(?:[?#].*)?$/i;

/** @type {undefined | ((specifier: string) => Promise<EXPECTED_ANY>)} */
let dynamicImport;
Expand Down Expand Up @@ -2079,21 +2080,16 @@ swcMinifyCss.getTypes = () => ["css"];
swcMinifyCss.filter = (name) => CSS_FILE_RE.test(name);

/**
* The extension a name carries, lowercased and without the dot or any query.
* The extension a name carries, lowercased and without the dot, query or fragment.
* @param {string} name asset name
* @returns {string} the extension, or "" when it has none
*/
function extensionOf(name) {
const withoutQuery = name.replace(/\?.*$/, "");
const dotIndex = withoutQuery.lastIndexOf(".");
const slashIndex = Math.max(
withoutQuery.lastIndexOf("/"),
withoutQuery.lastIndexOf("\\"),
);
const bare = name.replace(/[?#].*$/, "");
const dotIndex = bare.lastIndexOf(".");
const slashIndex = Math.max(bare.lastIndexOf("/"), bare.lastIndexOf("\\"));

return dotIndex > slashIndex
? withoutQuery.slice(dotIndex + 1).toLowerCase()
: "";
return dotIndex > slashIndex ? bare.slice(dotIndex + 1).toLowerCase() : "";
}

/**
Expand Down
37 changes: 37 additions & 0 deletions test/css-minify-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,41 @@ describe("css minify option", () => {
expect(getErrors(stats)).toMatchSnapshot("errors");
expect(getWarnings(stats)).toMatchSnapshot("warnings");
});

it("should minify CSS and HTML assets whose names carry a fragment", async () => {
const compiler = getCompiler({
entry: path.resolve(__dirname, "./fixtures/fragment-assets.js"),
output: {
path: path.resolve(__dirname, "./dist"),
filename: "[name].js",
assetModuleFilename: "[name][ext][query][fragment]",
},
});

new MinimizerPlugin({
test: /\.(?:css|html)$/i,
minify: [
MinimizerPlugin.cssnanoMinify,
MinimizerPlugin.htmlMinifierTerser,
],
minimizerOptions: [
{},
{ collapseWhitespace: true, removeComments: true },
],
}).apply(compiler);

const stats = await compile(compiler);
const assets = readsAssets(compiler, stats);

for (const name of ["file.css#dark", "file.html#top"]) {
expect(stats.compilation.getAsset(name).info.minimized).toBe(true);
}

expect(assets["file.css#dark"]).toBe(
".foo{color:red;background:blue}.bar{margin:10px;padding:10px}",
);
expect(assets["file.html#top"]).not.toMatch(/\n|<!--/);
expect(getErrors(stats)).toEqual([]);
expect(getWarnings(stats)).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions test/extractComments-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -692,4 +692,47 @@ describe("extractComments option", () => {
expect(getErrors(stats)).toMatchSnapshot("errors");
expect(getWarnings(stats)).toMatchSnapshot("warnings");
});

it.each([
["the default filename", undefined, "", ""],
["a function filename", createFilenameFn(), "", ""],
// `[query]` reads the `filename` handed to it, which carries none.
["the default filename and a query", undefined, "?v=1", ""],
["a function filename and a query", createFilenameFn(), "?v=1", "?v=1"],
])(
"should keep a fragment out of the extracted comments file with %s",
async (_, filename, query, licenseQuery) => {
compiler = getCompiler({
entry: path.resolve(__dirname, "./fixtures/comments.js"),
output: {
path: path.resolve(__dirname, "./dist"),
filename: `[name].js${query}#[fullhash]`,
chunkFilename: `[id].js${query}#[fullhash]`,
},
});

new MinimizerPlugin({
extractComments: filename ? { filename } : true,
}).apply(compiler);

const stats = await compile(compiler);
const names = Object.keys(stats.compilation.assets);

// Written as `main.js.LICENSE.txt`, not onto `main.js` with the bundle.
expect(names).toEqual(
expect.arrayContaining([
`main.js.LICENSE.txt${licenseQuery}`,
`203.js.LICENSE.txt${licenseQuery}`,
]),
);
expect(names.filter((name) => name.includes("LICENSE"))).toHaveLength(2);
expect(readAsset("main.js#x", compiler, stats)).toMatch(
new RegExp(
`^/\\*! For license information please see main\\.js\\.LICENSE\\.txt${licenseQuery.replace("?", "\\?")} \\*/`,
),
);
expect(getErrors(stats)).toEqual([]);
expect(getWarnings(stats)).toEqual([]);
},
);
});
3 changes: 3 additions & 0 deletions test/fixtures/fragment-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
console.log(new URL("./file.json#data", import.meta.url));
console.log(new URL("./file.css#dark", import.meta.url));
console.log(new URL("./file.html#top", import.meta.url));
2 changes: 1 addition & 1 deletion test/helpers/readAsset.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default (asset, compiler, stats) => {
let data = "";
let targetFile = asset;

const queryStringIdx = targetFile.indexOf("?");
const queryStringIdx = targetFile.search(/[?#]/);

if (queryStringIdx >= 0) {
targetFile = targetFile.slice(0, queryStringIdx);
Expand Down
123 changes: 123 additions & 0 deletions test/test-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,127 @@ describe("test option", () => {
expect(getErrors(stats)).toMatchSnapshot("errors");
expect(getWarnings(stats)).toMatchSnapshot("warnings");
});

it("should minify assets whose names carry a fragment", async () => {
// webpack names an asset module `[hash][ext][query][fragment]` by default,
// and `output.filename` may hold a `#` too; neither is on disk.
compiler = getCompiler({
entry: {
js: path.resolve(__dirname, "./fixtures/fragment-assets.js"),
mjs: path.resolve(__dirname, "./fixtures/entry.mjs"),
},
output: {
path: path.resolve(__dirname, "./dist"),
filename: (pathData) =>
pathData.chunk.name === "mjs"
? "[name].mjs#[fullhash]"
: "[name].js#[fullhash]",
assetModuleFilename: "[name][ext][query][fragment]",
},
plugins: [
{
// Emitted as a copy would be: no `javascriptModule`, so the name decides.
apply(childCompiler) {
childCompiler.hooks.thisCompilation.tap("Copy", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "Copy",
stage:
childCompiler.webpack.Compilation
.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const { RawSource } = childCompiler.webpack.sources;

for (const name of ["copy.mjs#m", "copy.cjs#c"]) {
compilation.emitAsset(
name,
new RawSource("var foo = 12;\nconsole.log(foo);\n"),
);
}
},
);
});
},
},
],
});

// What `.mjs` / `.cjs` read as, which the name decides past the fragment.
const moduleByName = new Map();
const terserMinify = (input, sourceMap, minimizerOptions) => {
for (const name of Object.keys(input)) {
moduleByName.set(name.replace(/#.*$/, ""), minimizerOptions.module);
}

return MinimizerPlugin.terserMinify(input, sourceMap, minimizerOptions);
};
Object.assign(terserMinify, MinimizerPlugin.terserMinify);

new MinimizerPlugin({
parallel: false,
test: /\.(?:[cm]?js|json)$/i,
minify: [terserMinify, MinimizerPlugin.jsonMinify],
}).apply(compiler);

const stats = await compile(compiler);
const assets = readsAssets(compiler, stats);
const names = Object.keys(assets);

expect(moduleByName.get("copy.mjs")).toBe(true);
expect(moduleByName.get("copy.cjs")).toBe(false);

expect(names).toEqual(
expect.arrayContaining([
expect.stringMatching(/^js\.js#[0-9a-f]+$/),
expect.stringMatching(/^mjs\.mjs#[0-9a-f]+$/),
"copy.mjs#m",
"copy.cjs#c",
"file.json#data",
]),
);

for (const name of names) {
if (/\.(?:[cm]?js|json)#/.test(name)) {
expect(stats.compilation.getAsset(name).info.minimized).toBe(true);
expect(assets[name]).not.toMatch(/\n/);
}
}

expect(getErrors(stats)).toEqual([]);
expect(getWarnings(stats)).toEqual([]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("should let each built-in `filter` accept a name with a fragment", () => {
const accepted = [
[MinimizerPlugin.terserMinify, "main.js#abc"],
[MinimizerPlugin.terserMinify, "main.mjs?v=1#abc"],
[MinimizerPlugin.uglifyJsMinify, "main.cjs#abc"],
[MinimizerPlugin.swcMinify, "main.js#abc"],
[MinimizerPlugin.esbuildMinify, "main.js#abc"],
[MinimizerPlugin.jsonMinify, "data.json#abc"],
[MinimizerPlugin.htmlMinifierTerser, "page.html#top"],
[MinimizerPlugin.minifyHtmlNode, "page.htm#top"],
[MinimizerPlugin.swcMinifyHtml, "page.html#top"],
[MinimizerPlugin.swcMinifyHtmlFragment, "page.html#top"],
[MinimizerPlugin.cssnanoMinify, "style.css#dark"],
[MinimizerPlugin.cssoMinify, "style.css#dark"],
[MinimizerPlugin.cleanCssMinify, "style.css#dark"],
[MinimizerPlugin.esbuildMinifyCss, "style.css#dark"],
[MinimizerPlugin.lightningCssMinify, "style.css#dark"],
[MinimizerPlugin.swcMinifyCss, "style.css#dark"],
[MinimizerPlugin.svgoMinify, "icon.svg#id"],
[MinimizerPlugin.imageminMinify, "photo.png#x"],
[MinimizerPlugin.imageminGenerate, "photo.png#x"],
[MinimizerPlugin.sharpMinify, "photo.png#x"],
[MinimizerPlugin.sharpGenerate, "photo.png#x"],
[MinimizerPlugin.napiRsImageMinify, "photo.png#x"],
];

for (const [minimizer, name] of accepted) {
expect(minimizer.filter(name)).toBe(true);
// The fragment is not an extension: `x.js#.css` stays JavaScript.
expect(minimizer.filter(`other.txt#${name}`)).toBe(false);
}
});
});
Loading