From 1790f6f59dceaa2a7cdb540d594ada94ba8f4bf8 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:43:02 +0000 Subject: [PATCH 1/6] fix: minify a `style=""` body as a rule's contents in every CSS minimizer webpack hands an HTML `style=""` to the renderer as `css` with `as: "block-contents"`. Only cssnano read a bare declaration list; csso and clean-css answered with nothing, erasing the attribute, lightningcss and swc failed to parse it, and esbuild rejected `as` as an unknown option. Each now drops `as` from its options and minifies the body inside a rule, reading the contents back out (webpack/webpack#22288). Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- .changeset/css-block-contents.md | 5 + src/utils.js | 163 +++++++++++++++--- .../embedded-protocol.test.js.snap | 12 ++ test/embedded-protocol.test.js | 78 ++++++++- types/utils.d.ts | 16 ++ 5 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 .changeset/css-block-contents.md diff --git a/.changeset/css-block-contents.md b/.changeset/css-block-contents.md new file mode 100644 index 00000000..adc21ccf --- /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 f70ea1ff..06dbe99d 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,35 @@ 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 match = /^a\s*\{([^{}]*)\}$/.exec(written); + + return match ? match[1].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 +1712,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 +1786,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,15 +1875,16 @@ 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 = {}) => { // `module` and `ecma` are JavaScript-only concepts; the dispatcher - // injects them for every minimizer, but esbuild's CSS transform - // rejects unknown options. + // injects them for every minimizer, and `as` is the body's rather than + // esbuild's, but esbuild's CSS transform rejects unknown options. delete esbuildOptions.ecma; delete esbuildOptions.module; + delete esbuildOptions.as; // Need deep copy objects to avoid https://github.com/terser/terser/issues/366 return { @@ -1837,11 +1904,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 +1929,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 +2017,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 +2125,46 @@ 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. + if (sourceMap && !contents) { 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 +3912,12 @@ compress.supportsWorker = () => false; compress.supportsWorkerThreads = () => false; module.exports = { + BLOCK_CONTENTS, CLASSIC_SCRIPT, EVENT_HANDLER, MODULE_SCRIPT, asFunction, + asRule, cleanCssMinify, compress, cssnanoMinify, @@ -3825,6 +3943,7 @@ module.exports = { packageVersion, readPreset, replaceExtension, + ruleBody, sharpGenerate, sharpMinify, svgoMinify, diff --git a/test/__snapshots__/embedded-protocol.test.js.snap b/test/__snapshots__/embedded-protocol.test.js.snap index 532c1843..2db87ed3 100644 --- a/test/__snapshots__/embedded-protocol.test.js.snap +++ b/test/__snapshots__/embedded-protocol.test.js.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cleanCssMinify\` 1`] = `"color:red;margin:0"`; + +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cssnanoMinify\` 1`] = `"color:red;margin:0"`; + +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cssoMinify\` 1`] = `"color:red;margin:0"`; + +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`esbuildMinifyCss\` 1`] = `"color:red;margin:0"`; + +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`lightningCssMinify\` 1`] = `"color:red;margin:0"`; + +exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`swcMinifyCss\` 1`] = `"color:red;margin:0"`; + exports[`a body handed out as a classic script is read as one by \`esbuildMinify\` 1`] = ` "var o={a:1};with(o)window.ran=a; " diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 3795bb0d..4482be39 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, @@ -334,6 +334,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 +859,45 @@ describe("a handler body a minimizer does not answer with the function", () => { ); }); }); + +describe("a body handed out as a block's contents", () => { + it.each([ + ["cssnanoMinify", MinimizerPlugin.cssnanoMinify], + ["cssoMinify", MinimizerPlugin.cssoMinify], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify], + ["esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss], + ["lightningCssMinify", MinimizerPlugin.lightningCssMinify], + ["swcMinifyCss", MinimizerPlugin.swcMinifyCss], + ])( + "is minified as the rule it belongs to by `%s`", + async (name, minifier) => { + const compiler = getPageCompiler([styleAttributeMinify, minifier]); + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(readAsset("host.page", compiler, stats)).toMatchSnapshot(); + }, + ); +}); + +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("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("b{color:red}")).toBeUndefined(); + expect(ruleBody("@media print{a{color:red}}")).toBeUndefined(); + expect(ruleBody(undefined)).toBeUndefined(); + }); +}); diff --git a/types/utils.d.ts b/types/utils.d.ts index 305b35d3..5ed9b1b1 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. * From 021b38278a999e21d7215ee0fcd5b8cbfc3be374 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:37:28 +0000 Subject: [PATCH 2/6] fix: read a rule's contents past a quoted brace, and keep the caller's options `ruleBody` now skips braces inside strings and comments instead of declining the rule, `esbuildMinifyCss` no longer deletes `as` from the options object it is handed, and `swcMinifyCss` drops a map the options asked for when the body was wrapped. Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- src/utils.js | 55 ++++++++++++++++++++++------ test/embedded-protocol.test.js | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/utils.js b/src/utils.js index 06dbe99d..ad2a8e21 100644 --- a/src/utils.js +++ b/src/utils.js @@ -86,9 +86,37 @@ function ruleBody(answered) { if (written === "") return ""; - const match = /^a\s*\{([^{}]*)\}$/.exec(written); + const opened = /^a\s*\{/.exec(written); - return match ? match[1].trim() : undefined; + if (!opened || !written.endsWith("}")) return undefined; + + const end = written.length - 1; + + // 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 === "{" || char === "}") return undefined; + + 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 || closed + 2 > end) return undefined; + + i = closed + 1; + } + } + + return written.slice(opened[0].length, end).trim(); } /** @@ -1878,23 +1906,23 @@ async function esbuildMinifyCss(input, sourceMap, minimizerOptions) { * @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, and `as` is the body's rather than // esbuild's, but esbuild's CSS transform rejects unknown options. - delete esbuildOptions.ecma; - delete esbuildOptions.module; - delete esbuildOptions.as; - + 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; @@ -2148,8 +2176,11 @@ async function swcMinifyCss(input, sourceMap, minimizerOptions) { const swcOptions = buildSwcOptions(minimizerOptions); // Let `swc` generate a SourceMap; a wrap moves every position, so the map - // would describe a stylesheet that is not what comes back. - if (sourceMap && !contents) { + // 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; } diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 4482be39..2c4f2edc 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -893,11 +893,78 @@ describe("the rule a block's contents are minified inside", () => { 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"); + }); + 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("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(); + }); +}); + +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("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([ + ["cssoMinify", MinimizerPlugin.cssoMinify, { sourceMap: true }], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify, { sourceMap: true }], + ["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) => { + // 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(); + }, + ); }); From a2e43c13534236a9a74533a1a2e8545c1145974d Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:39:19 +0000 Subject: [PATCH 3/6] test: run the CSS minimizer block-contents cases only where they install cssnano@8 reaches for `Array.prototype.difference`, so the Node < 22 and Windows rows skip them through `RUN_CSS_TESTS`, like `css-minify-option`. Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- .../embedded-protocol.test.js.snap | 12 +- test/embedded-protocol.test.js | 146 ++++++++++-------- 2 files changed, 85 insertions(+), 73 deletions(-) diff --git a/test/__snapshots__/embedded-protocol.test.js.snap b/test/__snapshots__/embedded-protocol.test.js.snap index 2db87ed3..9a186a0a 100644 --- a/test/__snapshots__/embedded-protocol.test.js.snap +++ b/test/__snapshots__/embedded-protocol.test.js.snap @@ -1,16 +1,16 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cleanCssMinify\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cleanCssMinify\` 1`] = `"color:red;margin:0"`; -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cssnanoMinify\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cssnanoMinify\` 1`] = `"color:red;margin:0"`; -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`cssoMinify\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cssoMinify\` 1`] = `"color:red;margin:0"`; -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`esbuildMinifyCss\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`esbuildMinifyCss\` 1`] = `"color:red;margin:0"`; -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`lightningCssMinify\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`lightningCssMinify\` 1`] = `"color:red;margin:0"`; -exports[`a body handed out as a block's contents is minified as the rule it belongs to by \`swcMinifyCss\` 1`] = `"color:red;margin:0"`; +exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`swcMinifyCss\` 1`] = `"color:red;margin:0"`; exports[`a body handed out as a classic script is read as one by \`esbuildMinify\` 1`] = ` "var o={a:1};with(o)window.ran=a; diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 2c4f2edc..0f38b1df 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -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 @@ -861,23 +865,25 @@ describe("a handler body a minimizer does not answer with the function", () => { }); describe("a body handed out as a block's contents", () => { - it.each([ - ["cssnanoMinify", MinimizerPlugin.cssnanoMinify], - ["cssoMinify", MinimizerPlugin.cssoMinify], - ["cleanCssMinify", MinimizerPlugin.cleanCssMinify], - ["esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss], - ["lightningCssMinify", MinimizerPlugin.lightningCssMinify], - ["swcMinifyCss", MinimizerPlugin.swcMinifyCss], - ])( - "is minified as the rule it belongs to by `%s`", - async (name, minifier) => { - const compiler = getPageCompiler([styleAttributeMinify, minifier]); - const stats = await compile(compiler); - - expect(getErrors(stats)).toEqual([]); - expect(readAsset("host.page", compiler, stats)).toMatchSnapshot(); - }, - ); + describeIf(RUN_CSS_TESTS)("where the CSS minimizers run", () => { + it.each([ + ["cssnanoMinify", MinimizerPlugin.cssnanoMinify], + ["cssoMinify", MinimizerPlugin.cssoMinify], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify], + ["esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss], + ["lightningCssMinify", MinimizerPlugin.lightningCssMinify], + ["swcMinifyCss", MinimizerPlugin.swcMinifyCss], + ])( + "is minified as the rule it belongs to by `%s`", + async (name, minifier) => { + const compiler = getPageCompiler([styleAttributeMinify, minifier]); + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + expect(readAsset("host.page", compiler, stats)).toMatchSnapshot(); + }, + ); + }); }); describe("the rule a block's contents are minified inside", () => { @@ -912,59 +918,65 @@ describe("the rule a block's contents are minified inside", () => { }); 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("leaves the options it was handed as they were", async () => { - const options = { as: "block-contents" }; - const input = { "style.css": " color : red " }; + describeIf(RUN_CSS_TESTS)("where the CSS minimizers run", () => { + it("minifies a string holding a brace", async () => { + const result = await MinimizerPlugin.cssoMinify( + { "style.css": ' content : "{" ' }, + undefined, + { as: "block-contents" }, + ); - const first = await MinimizerPlugin.esbuildMinifyCss( - input, - undefined, - options, - ); - const second = await MinimizerPlugin.esbuildMinifyCss( - input, - undefined, - options, - ); + expect(result.code).toBe('content:"{"'); + }); - expect(options).toEqual({ as: "block-contents" }); - expect(first.code).toBe("color:red"); - expect(second.code).toBe("color:red"); - }); + it("leaves the options it was handed as they were", async () => { + const options = { as: "block-contents" }; + const input = { "style.css": " color : red " }; - it.each([ - ["cssoMinify", MinimizerPlugin.cssoMinify, { sourceMap: true }], - ["cleanCssMinify", MinimizerPlugin.cleanCssMinify, { sourceMap: true }], - ["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) => { - // 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 }, + const first = await MinimizerPlugin.esbuildMinifyCss( + input, + undefined, + options, + ); + const second = await MinimizerPlugin.esbuildMinifyCss( + input, + undefined, + options, ); - expect(result.code).toBe("color:red"); - expect(result.map).toBeUndefined(); - }, - ); + expect(options).toEqual({ as: "block-contents" }); + expect(first.code).toBe("color:red"); + expect(second.code).toBe("color:red"); + }); + + it.each([ + ["cssoMinify", MinimizerPlugin.cssoMinify, { sourceMap: true }], + ["cleanCssMinify", MinimizerPlugin.cleanCssMinify, { sourceMap: true }], + [ + "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) => { + // 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(); + }, + ); + }); }); From a1985ad653622ca1a2ecd620e09ce10d5f13b915 Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:43:30 +0000 Subject: [PATCH 4/6] test: cover an escaped brace and a string or comment left open in `ruleBody` The comment check loses its `closed + 2 > end` half, which an answer ending in `}` can never reach. Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- src/utils.js | 2 +- test/embedded-protocol.test.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index ad2a8e21..8e967161 100644 --- a/src/utils.js +++ b/src/utils.js @@ -110,7 +110,7 @@ function ruleBody(answered) { } else if (char === "/" && written[i + 1] === "*") { const closed = written.indexOf("*/", i + 2); - if (closed === -1 || closed + 2 > end) return undefined; + if (closed === -1) return undefined; i = closed + 1; } diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 0f38b1df..c5e59acf 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -904,6 +904,8 @@ describe("the rule a block's contents are minified inside", () => { 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"); }); it("declines an answer that is not that one rule", () => { @@ -914,6 +916,9 @@ describe("the rule a block's contents are minified inside", () => { 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(); }); }); From beac4bbfeb47f026c6b9f238f9ffc293058c07ba Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:43:10 +0000 Subject: [PATCH 5/6] test: run the csso and clean-css block-contents cases on every row Both are plain JavaScript supporting Node 10, so only cssnano and the native minimizers stay behind `RUN_CSS_TESTS`. The page cases assert their output instead of snapshotting it: a snapshot in a skipped block is reported obsolete, which fails a `--ci` row. Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- .../embedded-protocol.test.js.snap | 12 -- test/embedded-protocol.test.js | 107 ++++++++++++------ 2 files changed, 73 insertions(+), 46 deletions(-) diff --git a/test/__snapshots__/embedded-protocol.test.js.snap b/test/__snapshots__/embedded-protocol.test.js.snap index 9a186a0a..532c1843 100644 --- a/test/__snapshots__/embedded-protocol.test.js.snap +++ b/test/__snapshots__/embedded-protocol.test.js.snap @@ -1,17 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cleanCssMinify\` 1`] = `"color:red;margin:0"`; - -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cssnanoMinify\` 1`] = `"color:red;margin:0"`; - -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`cssoMinify\` 1`] = `"color:red;margin:0"`; - -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`esbuildMinifyCss\` 1`] = `"color:red;margin:0"`; - -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`lightningCssMinify\` 1`] = `"color:red;margin:0"`; - -exports[`a body handed out as a block's contents where the CSS minimizers run is minified as the rule it belongs to by \`swcMinifyCss\` 1`] = `"color:red;margin:0"`; - exports[`a body handed out as a classic script is read as one by \`esbuildMinify\` 1`] = ` "var o={a:1};with(o)window.ran=a; " diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index c5e59acf..2a906b69 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -864,23 +864,45 @@ 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([ - ["cssnanoMinify", MinimizerPlugin.cssnanoMinify], - ["cssoMinify", MinimizerPlugin.cssoMinify], - ["cleanCssMinify", MinimizerPlugin.cleanCssMinify], - ["esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss], - ["lightningCssMinify", MinimizerPlugin.lightningCssMinify], - ["swcMinifyCss", MinimizerPlugin.swcMinifyCss], - ])( + it.each(MODERN_CSS_MINIMIZERS)( "is minified as the rule it belongs to by `%s`", async (name, minifier) => { - const compiler = getPageCompiler([styleAttributeMinify, minifier]); - const stats = await compile(compiler); - - expect(getErrors(stats)).toEqual([]); - expect(readAsset("host.page", compiler, stats)).toMatchSnapshot(); + await expectStyleAttributeMinified(minifier); }, ); }); @@ -922,18 +944,46 @@ describe("the rule a block's contents are minified inside", () => { }); }); +/** + * @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", () => { - describeIf(RUN_CSS_TESTS)("where the CSS minimizers run", () => { - it("minifies a string holding a brace", async () => { - const result = await MinimizerPlugin.cssoMinify( - { "style.css": ' content : "{" ' }, - undefined, - { as: "block-contents" }, - ); + 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:"{"'); - }); + 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 " }; @@ -955,8 +1005,6 @@ describe("a CSS minimizer handed a block's contents directly", () => { }); it.each([ - ["cssoMinify", MinimizerPlugin.cssoMinify, { sourceMap: true }], - ["cleanCssMinify", MinimizerPlugin.cleanCssMinify, { sourceMap: true }], [ "esbuildMinifyCss", MinimizerPlugin.esbuildMinifyCss, @@ -971,16 +1019,7 @@ describe("a CSS minimizer handed a block's contents directly", () => { ])( "returns no map for the rule `%s` minified it inside", async (name, 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(); + await expectNoMapForWrappedBody(minifier, mapOptions); }, ); }); From 1370bc987f9d9cba1dc8da40cb30e14a05f104bf Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:50:37 +0000 Subject: [PATCH 6/6] fix: read a rule's contents past a block a custom property's value holds `ruleBody` now tracks brace depth instead of declining any unquoted brace, so `--x:{a:b}` in a `style=""` is minified; a second rule or a block left open still declines. Claude-Session: https://claude.ai/code/session_01Vszf1WJhCqatsMQ2xZLF9x --- src/utils.js | 13 +++++++++---- test/embedded-protocol.test.js | 4 ++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/utils.js b/src/utils.js index 8e967161..1f0563f4 100644 --- a/src/utils.js +++ b/src/utils.js @@ -92,14 +92,19 @@ function ruleBody(answered) { 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 === "{" || char === "}") return undefined; - - if (char === "\\") { + 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++) { @@ -116,7 +121,7 @@ function ruleBody(answered) { } } - return written.slice(opened[0].length, end).trim(); + return depth === 0 ? written.slice(opened[0].length, end).trim() : undefined; } /** diff --git a/test/embedded-protocol.test.js b/test/embedded-protocol.test.js index 2a906b69..082c91a2 100644 --- a/test/embedded-protocol.test.js +++ b/test/embedded-protocol.test.js @@ -928,11 +928,15 @@ describe("the rule a block's contents are minified inside", () => { 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();