Skip to content

Commit 9d3b707

Browse files
feat(css): seven more value- and selector-level minify transforms (#21610)
* feat(css): drop the selectors a list repeats A repeat matches what its first spelling already matches, at the same specificity, so only the first is ever read. Written order is kept: the compressor reads a stylesheet in author order, where like rules already sit together, and sorting the list measured worse. * feat(css): squeeze the whitespace and number spelling a custom property carries A custom property's value is a token stream, so collapsing the whitespace between its tokens and normalizing how they are spelled leaves every `var()` substitution reading exactly the same stream. Only the spelling: `getComputedStyle().getPropertyValue()` hands this text back, so a rewrite that restates a value in another notation stays held back — a color, a hash, a time's unit, an easing keyword, a folded sum and a string's quotes all keep what was written. esbuild, clean-css, lightningcss and cssnano all rewrite here by default; csso does not. * feat(css): rewrite a custom property's colors, and write 2n+1 as odd A color a custom property carries is the same color spelled shorter, so it minifies like any other — only a rewrite the substitution could not undo stays held back (a unit, an easing keyword, a folded sum, a string). `odd` is the An+B notation Selectors 4 gives a keyword, and the keyword is the shorter spelling. The pseudo-classes it applies to are read out of mdn-data by the `<an+b>` their own grammar names, not listed by hand. * feat(css): drop a nested calc's keyword, and write a `from` keyframe as 0% CSS Values 4 §10.1: a `calc()` standing inside a math expression is what a plain parenthesis already says, so the keyword carries nothing — verified in headless Chromium for an operand, a `round()` argument, a `min()` one and a `clamp()` one, each computing identically either way. CSS Animations 1 §4 defines `from` as `0%`, two bytes shorter; `to` keeps its spelling, being shorter than the `100%` it names. * feat(css): drop the unit a zero angle does not need The functions taking a bare `0` where an angle belongs are the ones whose own grammar names `<zero>` beside `<angle>`, so the table is read out of mdn-data rather than listed. Verified in headless Chromium: rotate(), rotate3d(), skew(), skewX() and hue-rotate() each compute the same either way, and a function with no `<zero>` keeps its unit. * feat(css): write each unicode-range in its shortest equal spelling Leading zeros carry nothing, and a range running from a prefix followed by zeros to that prefix followed by `f`s is exactly what the `?` wildcard says — so `U+0025-00FF` is `U+25-FF` and `U+1E00-1EFF` is `U+1E??`. Chromium reads both spellings of each pair back as one value through the CSSOM, which is the equivalence stated. A range whose end is not aligned keeps its bounds. The generic numeric printer still never sees a `U+…`. * chore: fold the new CSS minify transforms into the pending changeset * test(css): update the cached-build snapshots and the words the new terms need `ConfigCacheTestCases` keeps its own snapshots, so the config cases the transforms changed needed updating there too. `urange` is the CSS Syntax production the unicode-range printer is named for; the transform-function names come out of the generated table lowercased. * fix(css): keep every transform to one that builds the same CSSOM `SyntaxBrowserEquivalence` drives real Chrome and asserts a minified stylesheet builds the CSSOM its source does. Two transforms broke that: - a custom property's value is the text `getPropertyValue()` hands back, so squeezing its whitespace or rewriting its colors builds a different CSSOM from the same document — whatever a `var()` substitution reads. That is a stricter contract than "renders the same", and it is webpack's. - dropping a selector a list repeats changes `selectorText` the same way, and measured byte-neutral on every fixture. Both are reverted. The zero-angle table is narrowed to the functions whose grammar names no type but `<angle>` / `<zero>`, so `rotate3d()` is out: its first three arguments are numbers, and giving `rotate3d(0deg,0,1,45deg)` a unitless zero revives a declaration Chromium drops. What remains — `odd`, a `from` keyframe, a nested `calc`, a zero angle and a `unicode-range` — was checked pairwise in Chromium and builds an identical CSSOM. * chore: drop the reverted transforms from the changeset * fix(css): keep a nested calc() — unwrapping it changes the CSSOM A declaration holding a substitution keeps its value as the text it was written with, so `calc(X)` -> `(X)` inside one builds a different CSSOM even where the expression means the same. * test(css): cover the urange fallback and the selector-list splitter The eight lines the patch left uncovered: a `unicode-range` that is no urange, and the depths a keyframe selector list's comma can sit inside.
1 parent 13135e7 commit 9d3b707

20 files changed

Lines changed: 384 additions & 60 deletions

File tree

‎.changeset/070-css-html-minify-transforms.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"webpack": minor
33
---
44

5-
Minify CSS and HTML further where the document is unchanged: CSS rounds a number to 6 significant digits, rewrites a time into the shorter of `ms` / `s`, folds `calc()` and every math function the spec's grammars name over constants whose result is exact, reducing `calc-size()`'s size in place, down to the sum of unlike units it reduces to, takes the parentheses off a folded negative where the property accepts one, drops a zero's unit inside a call whose every number is a length, converts every polar and Lab color function to hex where the engine agrees, merges four box longhands with unrelated declarations between them — sides or corners — the two a pair shorthand sets, and the slots of an order-free one where each value parses back into its own, collapses an `and` of two one-sided media-feature ranges into the interval it describes, and keeps a `@supports` condition as written; HTML normalizes each attribute against the element it is on.
5+
Minify CSS and HTML further where the document is unchanged: CSS rounds a number to 6 significant digits, rewrites a time into the shorter of `ms` / `s`, folds `calc()` and every math function the spec's grammars name over constants whose result is exact, reducing `calc-size()`'s size in place, down to the sum of unlike units it reduces to, takes the parentheses off a folded negative where the property accepts one, drops a zero's unit inside a call whose every number is a length, converts every polar and Lab color function to hex where the engine agrees, merges four box longhands with unrelated declarations between them — sides or corners — the two a pair shorthand sets, and the slots of an order-free one where each value parses back into its own, collapses an `and` of two one-sided media-feature ranges into the interval it describes, writes `2n+1` as the `odd` it names, a `from` keyframe as `0%` and each `unicode-range` in its shortest equal spelling, drops the unit a zero angle does not need, and keeps a `@supports` condition as written; HTML normalizes each attribute against the element it is on.

‎cspell.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"rgba",
1818
"longhands",
1919
"translatex",
20+
"rotatex",
21+
"rotatey",
22+
"rotatez",
23+
"urange",
2024
"rebeccapurple",
2125
"translatey",
2226
"translatez",

‎lib/css/data.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,28 @@ const SUBSTITUTION_FUNCTIONS = new Set([
758758
"var"
759759
]);
760760

761+
// The pseudo-class functions whose argument is An+B, where `2n+1` is the
762+
// notation `odd` names in one byte less.
763+
const NTH_PSEUDO_FUNCTIONS = new Set([
764+
"nth-child",
765+
"nth-last-child",
766+
"nth-last-of-type",
767+
"nth-of-type"
768+
]);
769+
770+
// The functions every argument of which is an angle, so a zero one needs no
771+
// unit wherever it stands.
772+
const ZERO_ANGLE_FUNCTIONS = new Set([
773+
"hue-rotate",
774+
"rotate",
775+
"rotatex",
776+
"rotatey",
777+
"rotatez",
778+
"skew",
779+
"skewx",
780+
"skewy"
781+
]);
782+
761783
// CSS Values 4's math functions: everything inside one is a math expression, so
762784
// `*` and `/` there are operators, and the whitespace around them carries nothing.
763785
const MATH_FUNCTIONS = new Set([
@@ -1940,6 +1962,7 @@ module.exports.MATH_FUNCTION_FOLD = MATH_FUNCTION_FOLD;
19401962
module.exports.MATH_FUNCTION_KEYWORDS = MATH_FUNCTION_KEYWORDS;
19411963
module.exports.MATH_FUNCTION_SUM_ARGUMENTS = MATH_FUNCTION_SUM_ARGUMENTS;
19421964
module.exports.NEGATIVE_ACCEPTING_PROPERTIES = NEGATIVE_ACCEPTING_PROPERTIES;
1965+
module.exports.NTH_PSEUDO_FUNCTIONS = NTH_PSEUDO_FUNCTIONS;
19431966
module.exports.ONE_VALUE_PAIR_SHORTHANDS = ONE_VALUE_PAIR_SHORTHANDS;
19441967
module.exports.PAIR_LONGHANDS = PAIR_LONGHANDS;
19451968
module.exports.QUARTER_TURN_ANGLE = QUARTER_TURN_ANGLE;
@@ -1949,6 +1972,7 @@ module.exports.STEPPED_FUNCTIONS = STEPPED_FUNCTIONS;
19491972
module.exports.SUBSTITUTION_FUNCTIONS = SUBSTITUTION_FUNCTIONS;
19501973
module.exports.UNIT_CONVERSION_TARGETS = UNIT_CONVERSION_TARGETS;
19511974
module.exports.UNIT_GROUP_BASE = UNIT_GROUP_BASE;
1975+
module.exports.ZERO_ANGLE_FUNCTIONS = ZERO_ANGLE_FUNCTIONS;
19521976
module.exports.ZERO_UNIT_KEEPING_PROPERTIES = ZERO_UNIT_KEEPING_PROPERTIES;
19531977
// The exact arithmetic the printer's own evaluator needs. Sorted after the
19541978
// tables: `import/order` orders exports by case, uppercase first.

‎lib/css/syntax.js‎

Lines changed: 166 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const {
3434
MATH_FUNCTION_KEYWORDS,
3535
MATH_FUNCTION_SUM_ARGUMENTS,
3636
NEGATIVE_ACCEPTING_PROPERTIES,
37+
NTH_PSEUDO_FUNCTIONS,
3738
ONE_VALUE_PAIR_SHORTHANDS,
3839
PAIR_LONGHANDS,
3940
RGB_TO_NAME,
@@ -42,6 +43,7 @@ const {
4243
SUBSTITUTION_FUNCTIONS,
4344
UNIT_CONVERSION_TARGETS,
4445
UNIT_GROUP_BASE,
46+
ZERO_ANGLE_FUNCTIONS,
4547
ZERO_UNIT_KEEPING_PROPERTIES,
4648
exactAdd,
4749
exactDivide,
@@ -1843,6 +1845,7 @@ const _setupParse = (input, lc) => {
18431845
_inValue = false;
18441846
_inSupportsPrelude = false;
18451847
_inMediaConditionPrelude = false;
1848+
_inCustomProperty = false;
18461849
_mathFunctionDepth = 0;
18471850
_steppedFunctionDepth = 0;
18481851
_convertLengthUnits = false;
@@ -3878,13 +3881,18 @@ const _walkRule = (node, parent, index, writer) => {
38783881
// gradient) knows it, while selector-prelude hashes never see it set.
38793882
const prev = _inValue;
38803883
const prevDeclaration = _valueDeclaration;
3884+
const prevCustom = _inCustomProperty;
38813885
_inValue = true;
38823886
if (_printing) _valueDeclaration = node;
3887+
// A custom property's value is the one an engine hands back verbatim, so
3888+
// its tokens print squeezed but as written (see `_inCustomProperty`).
3889+
if (_input.startsWith("--", _starts[i0])) _inCustomProperty = true;
38833890
for (let i = vs; i < ve; i++) {
38843891
_walkValue(_nodeRef(_flat[i]), node, i - vs, writer);
38853892
}
38863893
_inValue = prev;
38873894
_valueDeclaration = prevDeclaration;
3895+
_inCustomProperty = prevCustom;
38883896
}
38893897
}
38903898
_exitNode(node, parent, index, b, writer);
@@ -4464,7 +4472,9 @@ const _normalizeNumericToken = (text) => {
44644472
// rewrite that holds everywhere else does not hold in one: `4.5cm` and `45mm`
44654473
// are the same length, and headless Chromium reads `round(down,4.5cm,1.5cm)`
44664474
// as `3cm` but `round(down,45mm,15mm)` as `4.5cm`.
4467-
if (_inSupportsPrelude || _steppedFunctionDepth !== 0) return num + unit;
4475+
if (_inSupportsPrelude || _inCustomProperty || _steppedFunctionDepth !== 0) {
4476+
return num + unit;
4477+
}
44684478
if (ANGLE_UNITS.has(unit.toLowerCase())) return num + unit;
44694479
return _convertUnit(_roundSignificant(num), unit);
44704480
};
@@ -5649,6 +5659,67 @@ const _dropLegacyPseudoElementColon = (parts) => {
56495659
}
56505660
};
56515661

5662+
// One `urange` (CSS Syntax 3 §11.2): `U+` then hex digits, then either a `-`
5663+
// and a second run or trailing `?` wildcards. Case is insignificant.
5664+
const _URANGE_RE =
5665+
/^u\+(?:([\da-f]{0,6})(\?{1,6})|([\da-f]{1,6})(?:-([\da-f]{1,6}))?)$/i;
5666+
5667+
/**
5668+
* Write one `urange` in its shortest equal spelling: leading zeros carry
5669+
* nothing, and a range whose start is a prefix followed by zeros and whose end
5670+
* is that prefix followed by `f`s is what the `?` wildcard says.
5671+
* @param {string} range one urange token
5672+
* @returns {string} the shortest spelling, or `range` when nothing is shorter
5673+
*/
5674+
const _minifyUnicodeRange = (range) => {
5675+
const parts = _URANGE_RE.exec(range);
5676+
if (parts === null) return range;
5677+
/** @type {(hex: string) => string} */
5678+
const strip = (hex) => hex.replace(/^0+(?=.)/, "");
5679+
// `U+00??` covers what `U+??` does — the zeros are as leading as any other.
5680+
if (parts[2] !== undefined) {
5681+
const head = parts[1].replace(/^0+/, "");
5682+
const out = `U+${head}${parts[2]}`;
5683+
return out.length < range.length ? out : range;
5684+
}
5685+
const start = strip(parts[3]);
5686+
if (parts[4] === undefined) {
5687+
const out = `U+${start}`;
5688+
return out.length < range.length ? out : range;
5689+
}
5690+
const end = strip(parts[4]);
5691+
let best = `U+${start}-${end}`;
5692+
// The wildcard needs both bounds the same width to compare digit by digit.
5693+
if (start.length <= end.length) {
5694+
const padded = start.padStart(end.length, "0");
5695+
let wild = 0;
5696+
while (
5697+
wild < padded.length &&
5698+
padded.charAt(padded.length - 1 - wild) === "0" &&
5699+
end.charAt(end.length - 1 - wild).toLowerCase() === "f"
5700+
) {
5701+
wild++;
5702+
}
5703+
while (wild > 0) {
5704+
const head = padded.slice(0, padded.length - wild);
5705+
// `equalsLowerCase` lowercases only its first argument.
5706+
if (
5707+
equalsLowerCase(head, end.slice(0, end.length - wild).toLowerCase())
5708+
) {
5709+
const out = `U+${head.replace(/^0+/, "")}${"?".repeat(wild)}`;
5710+
if (out.length < best.length) best = out;
5711+
break;
5712+
}
5713+
wild--;
5714+
}
5715+
}
5716+
return best.length < range.length ? best : range;
5717+
};
5718+
5719+
// `@keyframes` and every vendor spelling of it, whose prelude is a name and
5720+
// whose child preludes are keyframe selectors rather than selector lists.
5721+
const KEYFRAMES_AT_RULE_RE = /^(?:-[a-z]+-)?keyframes$/i;
5722+
56525723
// Media Queries 4 §2.4: `min-`/`max-` prefixes exist only on range-type media
56535724
// features, and `min-X: Y` is exactly `X >= Y`. The range spelling is the newer
56545725
// one, so it is only reached for where the target reads it.
@@ -5739,6 +5810,43 @@ const _dropImpliedUniversalSelector = (parts) => {
57395810
}
57405811
};
57415812

5813+
/**
5814+
* Split a selector list at the commas that separate its selectors. A comma
5815+
* inside `:is(…)`, an attribute value or a string belongs to one selector.
5816+
* @param {string} prelude the printed selector list
5817+
* @returns {string[]} the selectors, in written order
5818+
*/
5819+
const _splitSelectorList = (prelude) => {
5820+
/** @type {string[]} */
5821+
const out = [];
5822+
let depth = 0;
5823+
let quote = "";
5824+
let start = 0;
5825+
for (let i = 0; i < prelude.length; i++) {
5826+
const c = prelude.charAt(i);
5827+
if (c === "\\") {
5828+
i++;
5829+
continue;
5830+
}
5831+
if (quote !== "") {
5832+
if (c === quote) quote = "";
5833+
continue;
5834+
}
5835+
if (c === '"' || c === "'") {
5836+
quote = c;
5837+
} else if (c === "(" || c === "[") {
5838+
depth++;
5839+
} else if (c === ")" || c === "]") {
5840+
depth--;
5841+
} else if (c === "," && depth === 0) {
5842+
out.push(prelude.slice(start, i));
5843+
start = i + 1;
5844+
}
5845+
}
5846+
out.push(prelude.slice(start));
5847+
return out;
5848+
};
5849+
57425850
// A substitution expands to a whole token sequence, so two identical references
57435851
// are not one repeated value: with `--x:1px 2px`, `margin:var(--x) var(--x)` is
57445852
// four values, not two. `--name(` is a custom function (CSS Functions).
@@ -6340,6 +6448,10 @@ const printer = (path, writer) => {
63406448
minify && _mathFunctionDepth !== 0 ? _TRIM_MATH : _TRIM_NOTHING
63416449
);
63426450
if (!minify) return `${name}(${inner})`;
6451+
// An+B in a selector: `2n+1` is what `odd` names, and shorter written so.
6452+
if (!path.inValue() && NTH_PSEUDO_FUNCTIONS.has(name.toLowerCase())) {
6453+
return `${name}(${inner.replace(/\s+/g, "") === "2n+1" ? "odd" : inner})`;
6454+
}
63436455
// Function names match ASCII case-insensitively; lowercase once for the
63446456
// three transforms below.
63456457
const fn = name.toLowerCase();
@@ -6366,7 +6478,7 @@ const printer = (path, writer) => {
63666478
// stay (dropping them turns a clamped negative into a parse error and a
63676479
// rounded fraction into one), and only the declaration printer — which
63686480
// knows the property — takes them off.
6369-
if (!_inSupportsPrelude) {
6481+
if (!_inSupportsPrelude && !_inCustomProperty) {
63706482
const term = _foldMathFunction(fn, inner);
63716483
if (term !== null) {
63726484
// A term that means the same bare needs no parentheses anywhere, so
@@ -6384,7 +6496,10 @@ const printer = (path, writer) => {
63846496
}
63856497
// An easing function is only ever a value; `url()` is also an at-rule
63866498
// prelude's (`@import url("a.css")`).
6387-
const easing = path.inValue() ? _minifyEasingFunction(fn, inner) : null;
6499+
const easing =
6500+
path.inValue() && !_inCustomProperty
6501+
? _minifyEasingFunction(fn, inner)
6502+
: null;
63886503
if (easing !== null) return easing;
63896504
// `translateX(v)` is `translate(v)` — the second component defaults to 0.
63906505
// Only for one plain component: a substitution could expand to two, which
@@ -6398,8 +6513,18 @@ const printer = (path, writer) => {
63986513
) {
63996514
return `translate(${inner})`;
64006515
}
6516+
// A zero angle needs no unit where the grammar names `<zero>` beside
6517+
// `<angle>` (CSS Transforms 2, Filter Effects 1).
6518+
if (path.inValue() && ZERO_ANGLE_FUNCTIONS.has(fn)) {
6519+
const bare = inner.replace(
6520+
/(^|,)\s*0(?:deg|grad|rad|turn)\s*(?=,|$)/gi,
6521+
"$10"
6522+
);
6523+
if (bare !== inner) return `${name}(${bare})`;
6524+
}
64016525
const url = _minifyUrlFunction(name, fn, inner);
6402-
return url !== null ? url : `${name}(${inner})`;
6526+
if (url !== null) return url;
6527+
return `${name}(${inner})`;
64036528
}
64046529
case T_URL: {
64056530
const raw = path.source();
@@ -6422,7 +6547,8 @@ const printer = (path, writer) => {
64226547
}
64236548
case T_STRING: {
64246549
const raw = path.source();
6425-
return minify ? _minifyString(raw) : raw;
6550+
// A custom property hands its string back as written, quotes included.
6551+
return minify && !_inCustomProperty ? _minifyString(raw) : raw;
64266552
}
64276553
case T_IDENT: {
64286554
const raw = path.source();
@@ -6485,17 +6611,22 @@ const printer = (path, writer) => {
64856611
// Property names match ASCII case-insensitively; a custom property's
64866612
// does not, and skipping it also skips the lowercasing.
64876613
const property = custom ? name : toLowerCaseIfNeeded(name);
6488-
// TODO offer the custom-property rewrites behind an option: the
6489-
// per-token ones are exact CSS (`--r:0.5rem` → `.5rem`, worth ~0.3% on
6490-
// utility-heavy sheets), but a custom property is the one declaration
6491-
// whose authored text `getComputedStyle().getPropertyValue()` hands
6492-
// back, and no other minifier rewrites it by default either.
6493-
if (custom || property === "unicode-range") {
6494-
// Custom-property value: verbatim (its internal whitespace matters), so
6495-
// concatenate the children's raw source rather than their printed text.
6496-
// unicode-range too: its `U+…` tokenizes as numbers, which numeric
6497-
// normalization would corrupt.
6614+
if (custom) {
6615+
// A custom property's value is the text `getPropertyValue()` hands
6616+
// back, so rewriting it builds a different CSSOM from the same
6617+
// document — which `SyntaxBrowserEquivalence` forbids, whatever a
6618+
// substitution would read. Verbatim, then.
64986619
value = children.map((c) => path.source(c)).join("");
6620+
} else if (property === "unicode-range") {
6621+
// `U+…` tokenizes as numbers, so the generic numeric normalization
6622+
// would corrupt it; each range is shortened as the urange it is.
6623+
const raw = children.map((c) => path.source(c)).join("");
6624+
value = minify
6625+
? raw
6626+
.split(",")
6627+
.map((one) => _minifyUnicodeRange(one.trim()))
6628+
.join(",")
6629+
: raw;
64996630
} else {
65006631
// Value hashes were already shortened by the hash printer (they print
65016632
// in a value context); just join the children's printed text.
@@ -6543,11 +6674,26 @@ const printer = (path, writer) => {
65436674
}
65446675
// Only a qualified rule's prelude is a selector — trim combinator spaces
65456676
// there; an at-rule prelude (media / container query) keeps its spacing.
6546-
const prelude = _join(
6677+
let prelude = _join(
65476678
parts,
65486679
!minify,
65496680
path.type() === T_QUALIFIED_RULE ? _TRIM_COMBINATORS : _TRIM_NOTHING
65506681
);
6682+
if (minify && path.type() === T_QUALIFIED_RULE) {
6683+
// A keyframe selector, not a selector list: `from` is the `0%` CSS
6684+
// Animations 1 §4 defines it as, written two bytes shorter. `to` keeps
6685+
// its spelling, being shorter than the `100%` it names.
6686+
const parent = path.parent;
6687+
if (
6688+
parent !== null &&
6689+
path.type(parent) === T_AT_RULE &&
6690+
KEYFRAMES_AT_RULE_RE.test(path.name(parent))
6691+
) {
6692+
prelude = _splitSelectorList(prelude)
6693+
.map((one) => (equalsLowerCase(one, "from") ? "0%" : one))
6694+
.join(",");
6695+
}
6696+
}
65516697
const decls = path.declarations();
65526698
// A qualified rule always has a block; an at-rule has one only when its
65536699
// declaration list is non-null — else it is `@…;`.
@@ -6751,6 +6897,10 @@ let _inSupportsPrelude = false;
67516897
// Set by the walk while inside a `@media` / `@container` prelude, where a `(…)`
67526898
// is a media feature rather than a declaration or a selector list.
67536899
let _inMediaConditionPrelude = false;
6900+
// Set by the walk while inside a custom property's value. Its tokens are
6901+
// squeezed, but a rewrite that restates a value in another notation is held
6902+
// back: `getPropertyValue()` hands this text back, so a color stays as written.
6903+
let _inCustomProperty = false;
67546904
// How many math functions the walk is inside. Non-zero is where `*` and `/` are
67556905
// operators that need no whitespace rather than value separators; above one is
67566906
// where a folded term is an operand of an outer expression rather than a value.

0 commit comments

Comments
 (0)