diff --git a/.changeset/css-block-contents.md b/.changeset/css-block-contents.md new file mode 100644 index 0000000..adc21cc --- /dev/null +++ b/.changeset/css-block-contents.md @@ -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. diff --git a/src/utils.js b/src/utils.js index f70ea1f..1f0563f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 @@ -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 `/package.json`, which a package whose @@ -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 @@ -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 }} */ ( @@ -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; @@ -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; } @@ -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 @@ -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>=} lightningCssOptions lightning css options + * @param {Partial> & { as?: string }=} lightningCssOptions lightning css options * @returns {import("lightningcss").TransformOptions} 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, @@ -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=} swcOptions swc options + * @param {Partial & { 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) @@ -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, @@ -3825,6 +3979,7 @@ module.exports = { packageVersion, readPreset, replaceExtension, + ruleBody, sharpGenerate, sharpMinify, svgoMinify, diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 3795bb0..082c91a 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -1,7 +1,7 @@ import path from "path"; import MinimizerPlugin from "../src"; -import { asFunction, functionBody } from "../src/utils"; +import { asFunction, asRule, functionBody, ruleBody } from "../src/utils"; import { compile, @@ -10,6 +10,10 @@ import { getWarnings, readAsset, } from "./helpers"; +import { RUN_CSS_TESTS } from "./helpers/env"; + +// The CSS minimizers need what `RUN_CSS_TESTS` says, like `css-minify-option`. +const describeIf = (condition) => (condition ? describe : describe.skip); // The `renderEmbeddedSource` dispatch on its own: a minimizer hands out what it // nests, each body goes to whichever minimizer claims its language, and the @@ -334,6 +338,40 @@ async function brokenHandlerMinify(input, sourceMap, minimizerOptions) { }; } +/** + * A declaration list, what an HTML `style=""` holds: no stylesheet production + * reads it, so a minimizer handed it as one drops or mangles it. + */ +const STYLE_ATTRIBUTE_BODY = " color : red ; margin : 0px "; + +/** + * A document minifier handing out one `style=""` body as the block's contents. + * @param {{ [file: string]: string }} input a single `{ filename: code }` entry + * @param {undefined} sourceMap unused + * @param {{ renderEmbeddedSource: (source: string, info: { type: string, as?: string }) => Promise }} minimizerOptions minimizer options + * @returns {Promise} the body as its minimizer wrote it + */ +async function styleAttributeMinify(input, sourceMap, minimizerOptions) { + const rendered = await askRenderer( + minimizerOptions.renderEmbeddedSource, + STYLE_ATTRIBUTE_BODY, + "css", + "block-contents", + ); + const text = answerText(rendered); + + return { + code: typeof text === "string" ? text : STYLE_ATTRIBUTE_BODY, + ...answerDiagnostics([rendered]), + }; +} + +styleAttributeMinify.getTypes = () => ["page"]; +styleAttributeMinify.getEmbeddedTypes = () => ["css"]; +styleAttributeMinify.supportsWorker = () => false; +styleAttributeMinify.supportsWorkerThreads = () => false; +styleAttributeMinify.filter = (name) => /\.page$/i.test(name); + brokenHandlerMinify.getTypes = () => ["page"]; brokenHandlerMinify.getEmbeddedTypes = () => ["javascript"]; brokenHandlerMinify.supportsWorker = () => false; @@ -825,3 +863,168 @@ describe("a handler body a minimizer does not answer with the function", () => { ); }); }); + +// csso and clean-css are plain JavaScript and run on every row; the rest need +// what `RUN_CSS_TESTS` says. Asserted rather than snapshotted, since a +// snapshot in a skipped block is reported obsolete (see `jest.config.js`). +const PORTABLE_CSS_MINIMIZERS = [ + ["cssoMinify", MinimizerPlugin.cssoMinify], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify], +]; +const MODERN_CSS_MINIMIZERS = [ + ["cssnanoMinify", MinimizerPlugin.cssnanoMinify], + ["esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss], + ["lightningCssMinify", MinimizerPlugin.lightningCssMinify], + ["swcMinifyCss", MinimizerPlugin.swcMinifyCss], +]; + +/** + * @param {EXPECTED_ANY} minifier a CSS minify function + * @returns {Promise} resolves once the page is checked + */ +const expectStyleAttributeMinified = async (minifier) => { + const compiler = getPageCompiler([styleAttributeMinify, minifier]); + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(readAsset("host.page", compiler, stats)).toBe("color:red;margin:0"); +}; + +describe("a body handed out as a block's contents", () => { + it.each(PORTABLE_CSS_MINIMIZERS)( + "is minified as the rule it belongs to by `%s`", + async (name, minifier) => { + await expectStyleAttributeMinified(minifier); + }, + ); + + describeIf(RUN_CSS_TESTS)("where the CSS minimizers run", () => { + it.each(MODERN_CSS_MINIMIZERS)( + "is minified as the rule it belongs to by `%s`", + async (name, minifier) => { + await expectStyleAttributeMinified(minifier); + }, + ); + }); +}); + +describe("the rule a block's contents are minified inside", () => { + it("makes the contents a whole stylesheet an engine can read", () => { + // The newline ends a bad string the contents may close with. + expect(asRule("color:red")).toBe("a{color:red\n}"); + }); + + it("reads the contents back out of what a minimizer answered", () => { + expect(ruleBody("a{color:red}")).toBe("color:red"); + expect(ruleBody("a {\n color: red;\n}\n")).toBe("color: red;"); + // A minimizer drops a rule left with no declarations. + expect(ruleBody("")).toBe(""); + }); + + it("reads past a brace a string or a comment holds", () => { + expect(ruleBody('a{content:"{"}')).toBe('content:"{"'); + expect(ruleBody("a{content:'}'}")).toBe("content:'}'"); + expect(ruleBody('a{content:"\\"}"}')).toBe('content:"\\"}"'); + expect(ruleBody("a{/* } */color:red}")).toBe("/* } */color:red"); + // An escaped brace is part of an identifier, not a block's edge. + expect(ruleBody("a{--x\\}:1}")).toBe("--x\\}:1"); + // A custom property's value may hold a block of its own. + expect(ruleBody("a{--x:{a:b};color:red}")).toBe("--x:{a:b};color:red"); + }); + + it("declines an answer that is not that one rule", () => { + expect(ruleBody("color:red")).toBeUndefined(); + expect(ruleBody("a{color:red}b{color:blue}")).toBeUndefined(); + expect(ruleBody("a{--x:{a:b}}b{color:blue}")).toBeUndefined(); + expect(ruleBody("a{--x:{a:b}")).toBeUndefined(); + expect(ruleBody("b{color:red}")).toBeUndefined(); + expect(ruleBody("@media print{a{color:red}}")).toBeUndefined(); + expect(ruleBody(undefined)).toBeUndefined(); + expect(ruleBody('a{content:"{"}b{color:blue}')).toBeUndefined(); + expect(ruleBody("a{color:red")).toBeUndefined(); + // A string or a comment left open reaches past the brace closing the rule. + expect(ruleBody('a{content:"}')).toBeUndefined(); + expect(ruleBody("a{color:red/*}")).toBeUndefined(); + }); +}); + +/** + * @param {EXPECTED_ANY} minifier a CSS minify function + * @param {Record} mapOptions how it spells asking for a map + * @returns {Promise} resolves once the answer is checked + */ +const expectNoMapForWrappedBody = async (minifier, mapOptions) => { + // The wrap moves every position, so a map asked for by the options too + // would describe a stylesheet that is not what comes back. + const result = await minifier( + { "style.css": " color : red " }, + { version: 3, sources: [], names: [], mappings: "" }, + { as: "block-contents", ...mapOptions }, + ); + + expect(result.code).toBe("color:red"); + expect(result.map).toBeUndefined(); +}; + +describe("a CSS minimizer handed a block's contents directly", () => { + it("minifies a string holding a brace", async () => { + const result = await MinimizerPlugin.cssoMinify( + { "style.css": ' content : "{" ' }, + undefined, + { as: "block-contents" }, + ); + + expect(result.code).toBe('content:"{"'); + }); + + it.each([ + ["cssoMinify", MinimizerPlugin.cssoMinify, { sourceMap: true }], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify, { sourceMap: true }], + ])( + "returns no map for the rule `%s` minified it inside", + async (name, minifier, mapOptions) => { + await expectNoMapForWrappedBody(minifier, mapOptions); + }, + ); + + describeIf(RUN_CSS_TESTS)("where the CSS minimizers run", () => { + it("leaves the options it was handed as they were", async () => { + const options = { as: "block-contents" }; + const input = { "style.css": " color : red " }; + + const first = await MinimizerPlugin.esbuildMinifyCss( + input, + undefined, + options, + ); + const second = await MinimizerPlugin.esbuildMinifyCss( + input, + undefined, + options, + ); + + expect(options).toEqual({ as: "block-contents" }); + expect(first.code).toBe("color:red"); + expect(second.code).toBe("color:red"); + }); + + it.each([ + [ + "esbuildMinifyCss", + MinimizerPlugin.esbuildMinifyCss, + { sourcemap: true }, + ], + [ + "lightningCssMinify", + MinimizerPlugin.lightningCssMinify, + { sourceMap: true }, + ], + ["swcMinifyCss", MinimizerPlugin.swcMinifyCss, { sourceMap: true }], + ])( + "returns no map for the rule `%s` minified it inside", + async (name, minifier, mapOptions) => { + await expectNoMapForWrappedBody(minifier, mapOptions); + }, + ); + }); +}); diff --git a/types/utils.d.ts b/types/utils.d.ts index 305b35d..5ed9b1b 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -26,6 +26,7 @@ export type ExtractedComments = string[]; export type QueryValues = { [name: string]: EXPECTED_ANY; }; +export const BLOCK_CONTENTS: "block-contents"; /** * Which production of a language an embedded body is written in, as `as` names * it. A `style=""` is `css` with `as: "block-contents"`; JavaScript's are these @@ -42,6 +43,14 @@ export const MODULE_SCRIPT: "module"; * @returns {string} the script it is the body of */ export function asFunction(body: string): string; +/** + * 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 + */ +export function asRule(body: string): string; /** * Minify CSS using `clean-css`. * @param {Input} input input @@ -622,6 +631,13 @@ export function readPreset(name: string): string | undefined; * @returns {string} the renamed asset */ export function replaceExtension(name: string, extension: string): string; +/** + * 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 + */ +export function ruleBody(answered: string | undefined): string | undefined; /** * Re-encode an image as another format with `sharp`, renaming it to match. *