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

Minify a `style=""` body as a rule's contents with every CSS minimizer, rather than dropping or rejecting it.
213 changes: 184 additions & 29 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const path = require("path");
const CLASSIC_SCRIPT = "script";
const MODULE_SCRIPT = "module";
const EVENT_HANDLER = "event-handler";
const BLOCK_CONTENTS = "block-contents";

/**
* The function a body handed out as an event handler belongs to. Named past any
Expand Down Expand Up @@ -61,6 +62,68 @@ function functionBody(answered) {
: undefined;
}

/**
* The rule a body handed out as a block's contents belongs to, since no
* stylesheet production reads a bare declaration list. The newline ends a bad
* string the body may close with.
* @param {string} body the block's contents
* @returns {string} the stylesheet it is the contents of
*/
function asRule(body) {
return `a{${body}\n}`;
}

/**
* The block's contents inside the rule a minimizer answered with: empty where
* it dropped the rule as holding nothing, `undefined` for any other answer.
* @param {string | undefined} answered what the minimizer answered
* @returns {string | undefined} the contents, or undefined
*/
function ruleBody(answered) {
if (typeof answered !== "string") return undefined;

const written = answered.trim();

if (written === "") return "";

const opened = /^a\s*\{/.exec(written);

if (!opened || !written.endsWith("}")) return undefined;

const end = written.length - 1;

let depth = 0;

// A brace a string or a comment holds is text rather than a block's edge, and
// one left open would reach past the brace closing the rule.
for (let i = opened[0].length; i < end; i++) {
const char = written[i];

if (char === "{") {
depth++;
} else if (char === "}") {
// Below zero is this rule closing before the answer ends.
if (--depth < 0) return undefined;
} else if (char === "\\") {
i++;
} else if (char === '"' || char === "'") {
for (i++; i < end && written[i] !== char; i++) {
if (written[i] === "\\") i++;
}

if (i >= end) return undefined;
} else if (char === "/" && written[i + 1] === "*") {
const closed = written.indexOf("*/", i + 2);

if (closed === -1) return undefined;

i = closed + 1;
}
}

return depth === 0 ? written.slice(opened[0].length, end).trim() : undefined;
}

/**
* The version a package reports. Read by walking up from its resolved entry
* point rather than by requiring `<name>/package.json`, which a package whose
Expand Down Expand Up @@ -1682,14 +1745,30 @@ async function cssoMinify(input, sourceMap, minimizerOptions) {
return { errors: [/** @type {Error} */ (err)] };
}

// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { BLOCK_CONTENTS, asRule, ruleBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

const { as, ...cssoOptions } = minimizerOptions || {};
const contents = as === BLOCK_CONTENTS;
const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const result = csso.minify(code, {
const result = csso.minify(contents ? asRule(code) : code, {
filename,
sourceMap: Boolean(sourceMap),
...minimizerOptions,
...cssoOptions,
});

if (contents) {
const body = ruleBody(result.css);

// A wrap moves every position, so the map describes a stylesheet that is
// not what comes back.
return { code: body === undefined ? code : body };
}

return {
code: result.css,
map: result.map
Expand Down Expand Up @@ -1740,13 +1819,33 @@ async function cleanCssMinify(input, sourceMap, minimizerOptions) {
return { errors: [/** @type {Error} */ (err)] };
}

// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { BLOCK_CONTENTS, asRule, ruleBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

const { as, ...cleanCssOptions } = minimizerOptions || {};
const contents = as === BLOCK_CONTENTS;
const [[name, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const result = await new CleanCSS({
sourceMap: Boolean(sourceMap),
...minimizerOptions,
...cleanCssOptions,
returnPromise: true,
}).minify({ [name]: { styles: code } });
}).minify({ [name]: { styles: contents ? asRule(code) : code } });

if (contents) {
const body = ruleBody(result.styles);

// A wrap moves every position, so the map describes a stylesheet that is
// not what comes back.
return {
code: body === undefined ? code : body,
warnings: result.warnings,
};
}

const generatedSourceMap = result.sourceMap
? /** @type {RawSourceMap} */ (
/** @type {{ toJSON(): RawSourceMap }} */ (
Expand Down Expand Up @@ -1809,25 +1908,26 @@ cleanCssMinify.filter = (name) => CSS_FILE_RE.test(name);
*/
async function esbuildMinifyCss(input, sourceMap, minimizerOptions) {
/**
* @param {import("esbuild").TransformOptions & { ecma?: string | number, module?: boolean }=} esbuildOptions esbuild options
* @param {import("esbuild").TransformOptions & { ecma?: string | number, module?: boolean, as?: string }=} esbuildOptions esbuild options
* @returns {import("esbuild").TransformOptions} built esbuild options
*/
const buildEsbuildOptions = (esbuildOptions = {}) => {
const buildEsbuildOptions = ({
// `module` and `ecma` are JavaScript-only concepts; the dispatcher
// injects them for every minimizer, but esbuild's CSS transform
// rejects unknown options.
delete esbuildOptions.ecma;
delete esbuildOptions.module;

// injects them for every minimizer, and `as` is the body's rather than
// esbuild's, but esbuild's CSS transform rejects unknown options.
ecma,
module,
as,
...esbuildOptions
} = {}) =>
// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
return {
({
loader: "css",
minify: true,
legalComments: "inline",
...esbuildOptions,
sourcemap: false,
};
};
});

let esbuild;

Expand All @@ -1837,11 +1937,22 @@ async function esbuildMinifyCss(input, sourceMap, minimizerOptions) {
return { errors: [/** @type {Error} */ (err)] };
}

// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { BLOCK_CONTENTS, asRule, ruleBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

const contents =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === BLOCK_CONTENTS;

// Copy `esbuild` options
const esbuildOptions = buildEsbuildOptions(minimizerOptions);

// Let `esbuild` generate a SourceMap
if (sourceMap) {
// Let `esbuild` generate a SourceMap; a wrap moves every position, so the
// map would describe a stylesheet that is not what comes back.
if (sourceMap && !contents) {
esbuildOptions.sourcemap = true;
esbuildOptions.sourcesContent = false;
}
Expand All @@ -1851,10 +1962,14 @@ async function esbuildMinifyCss(input, sourceMap, minimizerOptions) {

esbuildOptions.sourcefile = filename;

const result = await esbuild.transform(code, esbuildOptions);
const result = await esbuild.transform(
contents ? asRule(code) : code,
esbuildOptions,
);
const body = contents ? ruleBody(result.code) : undefined;

return {
code: result.code,
code: contents ? (body === undefined ? code : body) : result.code,
map: result.map ? JSON.parse(result.map) : undefined,
warnings:
result.warnings.length > 0
Expand Down Expand Up @@ -1935,33 +2050,49 @@ async function lightningCssMinify(input, sourceMap, minimizerOptions) {
return { errors: [/** @type {Error} */ (err)] };
}

// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { BLOCK_CONTENTS, asRule, ruleBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

const contents =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === BLOCK_CONTENTS;
const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
/**
* @param {Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>>=} lightningCssOptions lightning css options
* @param {Partial<import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>> & { as?: string }=} lightningCssOptions lightning css options
* @returns {import("lightningcss").TransformOptions<import("lightningcss").CustomAtRules>} built lightning css options
*/
const buildLightningCssOptions = (lightningCssOptions = {}) =>
const buildLightningCssOptions = ({ as, ...lightningCssOptions } = {}) =>
// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
({
minify: true,
...lightningCssOptions,
sourceMap: false,
filename,
code: new Uint8Array(Buffer.from(code)),
code: new Uint8Array(Buffer.from(contents ? asRule(code) : code)),
});

// Copy `lightningCss` options
const lightningCssOptions = buildLightningCssOptions(minimizerOptions);

// Let `lightningcss` generate a SourceMap. The dispatcher in
// `minify.js` chains the previous step's map onto this one.
if (sourceMap) {
// `minify.js` chains the previous step's map onto this one. A wrap moves
// every position, so the map would describe what does not come back.
if (sourceMap && !contents) {
lightningCssOptions.sourceMap = true;
}

const result = lightningCss.transform(lightningCssOptions);

if (contents) {
const body = ruleBody(result.code.toString());

return { code: body === undefined ? code : body };
}

return {
code: result.code.toString(),
map: result.map ? JSON.parse(result.map.toString()) : undefined,
Expand Down Expand Up @@ -2027,28 +2158,49 @@ async function swcMinifyCss(input, sourceMap, minimizerOptions) {
return { errors: [/** @type {Error} */ (err)] };
}

// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { BLOCK_CONTENTS, asRule, ruleBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

const contents =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === BLOCK_CONTENTS;
const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
/**
* @param {Partial<import("@swc/css").MinifyOptions>=} swcOptions swc options
* @param {Partial<import("@swc/css").MinifyOptions> & { as?: string }=} swcOptions swc options
* @returns {import("@swc/css").MinifyOptions} built swc options
*/
const buildSwcOptions = (swcOptions = {}) =>
const buildSwcOptions = ({ as, ...swcOptions } = {}) =>
// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
({ ...swcOptions, filename });

// Copy `swc` options
const swcOptions = buildSwcOptions(minimizerOptions);

// Let `swc` generate a SourceMap
if (sourceMap) {
// Let `swc` generate a SourceMap; a wrap moves every position, so the map
// would describe a stylesheet that is not what comes back, whatever the
// options asked for.
if (contents) {
swcOptions.sourceMap = false;
} else if (sourceMap) {
swcOptions.sourceMap = true;
}

const result = await swc.minify(Buffer.from(code), swcOptions);
const result = await swc.minify(
Buffer.from(contents ? asRule(code) : code),
swcOptions,
);
const body = contents ? ruleBody(result.code.toString()) : undefined;

return {
code: result.code.toString(),
code: contents
? body === undefined
? code
: body
: result.code.toString(),
map: result.map ? JSON.parse(result.map.toString()) : undefined,
errors: result.errors
? result.errors.map(swcCssDiagnosticToError)
Expand Down Expand Up @@ -3796,10 +3948,12 @@ compress.supportsWorker = () => false;
compress.supportsWorkerThreads = () => false;

module.exports = {
BLOCK_CONTENTS,
CLASSIC_SCRIPT,
EVENT_HANDLER,
MODULE_SCRIPT,
asFunction,
asRule,
cleanCssMinify,
compress,
cssnanoMinify,
Expand All @@ -3825,6 +3979,7 @@ module.exports = {
packageVersion,
readPreset,
replaceExtension,
ruleBody,
sharpGenerate,
sharpMinify,
svgoMinify,
Expand Down
Loading
Loading