Skip to content

Commit b27aa48

Browse files
feat(css): fold longhands into their shorthand, and shorten flex and basic shapes
Three rewrites lightningcss makes and webpack did not, each checked against the computed style Chromium reads back: - A longhand right after its own shorthand folds into it: the order-free families (`border-top`, `outline`, `column-rule`, `text-emphasis`, `flex-flow`, `text-decoration` where the target reads every slot), `flex`, and a one-layer `transition` or `animation`. `text-wrap` stays out, read as one keyword before it had longhands, and so does IE10's `-ms-flex`. - `flex` in fewer than three components reads as its three factors, so `1 auto` is `auto` and `1 1` is `1`. - A basic shape drops its defaults: a `closest-side` radius, a zero `round`, `nonzero`, and the sides `inset()` repeats. Not the `at` position, which engines read back as written. The `animation` keyword slots come from the generator, out of mdn-data. Writing four border sides as `border-width`/`-style`/`-color` saved raw bytes but cost gzip on every stylesheet it touched, and the 3/4-value `background-position` collapse changes what Chromium reads back, so neither is here. Over the 30 comparison stylesheets, extracted license comments, the gzip total is 56 B smaller and 1,804 B under lightningcss's.
1 parent 745ba27 commit b27aa48

12 files changed

Lines changed: 832 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Shorten more CSS when minifying: `flex` written in fewer than three components, basic shapes' default arguments, and a longhand folded into the shorthand right before it.

‎lib/css/data.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,28 @@ const FAMILY_LONGHANDS = new Map([
627627
["text-wrap", ["text-wrap-mode", "text-wrap-style"]]
628628
]);
629629

630+
// The `animation` longhands whose every value is a keyword, and those keywords.
631+
const ANIMATION_KEYWORD_SLOTS = new Map([
632+
[
633+
"animation-timing-function",
634+
[
635+
"ease",
636+
"ease-in",
637+
"ease-in-out",
638+
"ease-out",
639+
"linear",
640+
"step-end",
641+
"step-start"
642+
]
643+
],
644+
[
645+
"animation-direction",
646+
["alternate", "alternate-reverse", "normal", "reverse"]
647+
],
648+
["animation-fill-mode", ["backwards", "both", "forwards", "none"]],
649+
["animation-play-state", ["paused", "running"]]
650+
]);
651+
630652
// The properties whose comma-separated items take a `<custom-ident>`, where a
631653
// vendor spelling is a name the engine parses rather than one it may drop — so a
632654
// later declaration listing an earlier one's items cannot be its fallback.
@@ -8177,6 +8199,7 @@ module.exports.ANGLE_UNITS = ANGLE_UNITS;
81778199
module.exports.ARC_COSINE_DEGREES = ARC_COSINE_DEGREES;
81788200
module.exports.ARC_SINE_DEGREES = ARC_SINE_DEGREES;
81798201
module.exports.ARC_TANGENT_DEGREES = ARC_TANGENT_DEGREES;
8202+
module.exports.ANIMATION_KEYWORD_SLOTS = ANIMATION_KEYWORD_SLOTS;
81808203
module.exports.AUTO_SECOND_VALUE_PROPERTIES = AUTO_SECOND_VALUE_PROPERTIES;
81818204
module.exports.BOX_FAMILY_PREFIX = BOX_FAMILY_PREFIX;
81828205
module.exports.BOX_LONGHANDS = BOX_LONGHANDS;

‎lib/css/syntax-parser.js‎

Lines changed: 286 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const { makeCacheable } = require("../util/identifier");
2323
*/
2424
const {
2525
ABSOLUTE_UNIT_SCALE,
26+
ANIMATION_KEYWORD_SLOTS,
2627
BOX_FAMILY_PREFIX,
2728
BOX_LONGHANDS,
2829
CALC_CONSTANTS,
@@ -32,6 +33,7 @@ const {
3233
CSS_WIDE_KEYWORDS,
3334
CUSTOM_IDENT_LIST_PROPERTIES,
3435
DROPPABLE_WHEN_EMPTY_AT_RULES,
36+
EASING_KEYWORDS,
3537
ENCODED_ALREADY,
3638
ENGINE_TRANSFER_DIFFERS,
3739
FAMILY_LONGHANDS,
@@ -65,6 +67,7 @@ const {
6567
SUPPORT_BROWSERS,
6668
THROUGH_MATRIX,
6769
THROUGH_TRANSFER,
70+
TRANSITION_BEHAVIORS,
6871
UNSHARED_LONGHAND_KEYWORDS,
6972
foldAdd,
7073
foldDivide,
@@ -12123,7 +12126,19 @@ const _foldInto = (path, items, texts, at, from) => {
1212312126
if (shorthandColon <= 0 || longhandColon <= 0) return false;
1212412127
const property = _printedProperty(shorthandText, shorthandColon);
1212512128
const sides = BOX_LONGHANDS.get(property) || PAIR_LONGHANDS.get(property);
12126-
if (sides === undefined) return false;
12129+
if (sides === undefined) {
12130+
const folded = _foldIntoSlots(
12131+
property,
12132+
_printedValue(shorthandText, shorthandColon),
12133+
_printedProperty(longhandText, longhandColon),
12134+
_printedValue(longhandText, longhandColon)
12135+
);
12136+
if (folded === null) return false;
12137+
// Never longer: the slot's value is written once, its name no more.
12138+
const important = path.important(items[at]) ? _IMPORTANT : "";
12139+
texts[at] = `${property}:${folded}${important};`;
12140+
return true;
12141+
}
1212712142
const slot = sides.indexOf(_printedProperty(longhandText, longhandColon));
1212812143
if (slot === -1) return false;
1212912144
const shorthandValue = _printedValue(shorthandText, shorthandColon);
@@ -12169,6 +12184,221 @@ const _foldInto = (path, items, texts, at, from) => {
1216912184
return true;
1217012185
};
1217112186

12187+
// A `<time>`, which a `transition` or an `animation` reads as its duration
12188+
// first and its delay second.
12189+
const _TIME_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:s|ms)$/i;
12190+
const _ZERO_TIME_RE = /^[+-]?(?:0+\.?0*|\.0+)(?:s|ms)$/i;
12191+
// An easing function a layer may hold, the keywords aside.
12192+
const _EASING_CALL_RE = /^(?:cubic-bezier|steps|linear)\(/i;
12193+
12194+
/**
12195+
* A longhand's value written into the shorthand of its own family standing
12196+
* before it, or null where the shorthand's slots cannot be read apart.
12197+
* @param {string} property the shorthand's printed property name
12198+
* @param {string} shorthandValue its printed value
12199+
* @param {string} longhand the longhand's printed property name
12200+
* @param {string} longhandValue its printed value
12201+
* @returns {string | null} the shorthand's new value, or null
12202+
*/
12203+
const _foldIntoSlots = (property, shorthandValue, longhand, longhandValue) => {
12204+
// A vendor spelling folds only into the shorthand of the same vendor.
12205+
const prefix = /^-[a-z]+-/.exec(property);
12206+
const base = prefix === null ? property : property.slice(prefix[0].length);
12207+
if (prefix !== null && !longhand.startsWith(prefix[0])) return null;
12208+
const slot = prefix === null ? longhand : longhand.slice(prefix[0].length);
12209+
// A substitution may stand for any slot, and a comma list is more than one layer.
12210+
if (_hasSubstitution(shorthandValue) || _hasSubstitution(longhandValue)) {
12211+
return null;
12212+
}
12213+
if (
12214+
_valueItems(shorthandValue).length !== 1 ||
12215+
_valueItems(longhandValue).length !== 1
12216+
) {
12217+
return null;
12218+
}
12219+
const components = _splitPrintedComponents(shorthandValue);
12220+
const value = _splitPrintedComponents(longhandValue);
12221+
if (value.length !== 1 || components.length === 0) return null;
12222+
for (const one of [...components, value[0]]) {
12223+
if (CSS_WIDE_KEYWORDS.has(toLowerCaseIfNeeded(one))) return null;
12224+
}
12225+
if (base === "flex") {
12226+
// IE10's `-ms-flex` reads omitted factors the 2012 draft's way.
12227+
if (prefix !== null && prefix[0] === "-ms-") return null;
12228+
const factors = _flexFactors(components);
12229+
const at = ["flex-grow", "flex-shrink", "flex-basis"].indexOf(slot);
12230+
if (factors === null || at === -1) return null;
12231+
if (_BARE_NUMBER_RE.test(value[0]) !== (at !== 2)) return null;
12232+
factors[at] = value[0];
12233+
return _joinSlots(_shortestFlexValue(factors) || factors);
12234+
}
12235+
if (base === "transition") {
12236+
return _foldTransitionSlot(components, slot, value[0]);
12237+
}
12238+
if (base === "animation") {
12239+
return _foldAnimationSlot(components, slot, value[0]);
12240+
}
12241+
const longhands = FAMILY_LONGHANDS.get(base);
12242+
// `text-wrap` was one keyword before it had longhands, and an engine reading
12243+
// it so drops the whole of a value with two.
12244+
if (
12245+
longhands === undefined ||
12246+
base === "text-wrap" ||
12247+
!_shorthandSlotsReadable(base)
12248+
) {
12249+
return null;
12250+
}
12251+
const filled = _familySlots(longhands, components);
12252+
const taken = _familySlots(longhands, value);
12253+
if (filled === null || taken === null || !taken.has(slot)) return null;
12254+
filled.set(slot, value[0]);
12255+
const order = longhands.filter((name) => filled.has(name));
12256+
return _familyValue(
12257+
base,
12258+
order,
12259+
order.map((name) => /** @type {string} */ (filled.get(name)))
12260+
);
12261+
};
12262+
12263+
/**
12264+
* A `transition` longhand written into a one-layer `transition`, its slots
12265+
* then written in the order the printer writes an authored one.
12266+
* @param {string[]} components the layer's components
12267+
* @param {string} slot the longhand's unprefixed name
12268+
* @param {string} value its one component
12269+
* @returns {string | null} the layer's new value, or null
12270+
*/
12271+
const _foldTransitionSlot = (components, slot, value) => {
12272+
const names = [];
12273+
const times = [];
12274+
const easings = [];
12275+
const behaviors = [];
12276+
for (const one of components) {
12277+
const lower = toLowerCaseIfNeeded(one);
12278+
if (_TIME_RE.test(one)) {
12279+
times.push(one);
12280+
} else if (EASING_KEYWORDS.has(lower) || _EASING_CALL_RE.test(one)) {
12281+
easings.push(one);
12282+
} else if (TRANSITION_BEHAVIORS.has(lower)) {
12283+
behaviors.push(one);
12284+
} else {
12285+
names.push(one);
12286+
}
12287+
}
12288+
// An easing keyword is a property name too, so which slot it fills is the
12289+
// engine's to say where no other name stands beside it.
12290+
if (names.length > 1 || times.length > 2 || easings.length > 1) return null;
12291+
if (behaviors.length > 1) return null;
12292+
const lower = toLowerCaseIfNeeded(value);
12293+
if (slot === "transition-duration" || slot === "transition-delay") {
12294+
if (!_TIME_RE.test(value)) return null;
12295+
if (slot === "transition-duration") {
12296+
times[0] = value;
12297+
} else {
12298+
// A delay is the second time, so the first has to be written.
12299+
if (times.length === 0) return null;
12300+
times[1] = value;
12301+
}
12302+
} else if (slot === "transition-timing-function") {
12303+
if (!EASING_KEYWORDS.has(lower) && !_EASING_CALL_RE.test(value)) {
12304+
return null;
12305+
}
12306+
easings[0] = value;
12307+
} else if (slot === "transition-behavior") {
12308+
if (!TRANSITION_BEHAVIORS.has(lower)) return null;
12309+
behaviors[0] = value;
12310+
} else if (slot === "transition-property") {
12311+
// A name that is a keyword of another slot reads as that slot.
12312+
if (
12313+
_TIME_RE.test(value) ||
12314+
EASING_KEYWORDS.has(lower) ||
12315+
TRANSITION_BEHAVIORS.has(lower) ||
12316+
lower === "none"
12317+
) {
12318+
return null;
12319+
}
12320+
names[0] = value;
12321+
} else {
12322+
return null;
12323+
}
12324+
// What a slot holding its own initial says, it says unwritten too.
12325+
if (times.length === 2 && _ZERO_TIME_RE.test(times[1])) times.pop();
12326+
if (times.length === 1 && _ZERO_TIME_RE.test(times[0])) times.pop();
12327+
if (easings.length === 1 && equalsLowerCase(easings[0], "ease")) {
12328+
easings.pop();
12329+
}
12330+
if (behaviors.length === 1 && equalsLowerCase(behaviors[0], "normal")) {
12331+
behaviors.pop();
12332+
}
12333+
const rest = [...times, ...easings, ...behaviors];
12334+
const named =
12335+
names.length === 1 && equalsLowerCase(names[0], "all") && rest.length > 0
12336+
? []
12337+
: names;
12338+
const out = [...named, ...rest];
12339+
return out.length === 0 ? "all" : _joinSlots(out);
12340+
};
12341+
12342+
/**
12343+
* An `animation` longhand written into a one-layer `animation` whose every
12344+
* component reads as one slot alone. The new component goes last, where an
12345+
* empty slot takes it; one already filled is replaced where it stands.
12346+
* @param {string[]} components the layer's components
12347+
* @param {string} slot the longhand's unprefixed name
12348+
* @param {string} value its one component
12349+
* @returns {string | null} the layer's new value, or null
12350+
*/
12351+
const _foldAnimationSlot = (components, slot, value) => {
12352+
const lower = toLowerCaseIfNeeded(value);
12353+
/** @type {(one: string, lowered: string) => boolean} */
12354+
let fills;
12355+
if (slot === "animation-duration" || slot === "animation-delay") {
12356+
if (!_TIME_RE.test(value)) return null;
12357+
fills = (one) => _TIME_RE.test(one);
12358+
} else if (slot === "animation-iteration-count") {
12359+
if (lower !== "infinite" && !_BARE_NUMBER_RE.test(value)) return null;
12360+
fills = (one, lowered) =>
12361+
lowered === "infinite" || _BARE_NUMBER_RE.test(one);
12362+
} else {
12363+
const keywords = ANIMATION_KEYWORD_SLOTS.get(slot);
12364+
if (keywords === undefined) return null;
12365+
// The easing slot also takes a call, which no other slot does.
12366+
const calls = slot === "animation-timing-function";
12367+
if (!keywords.includes(lower) && !(calls && _EASING_CALL_RE.test(value))) {
12368+
return null;
12369+
}
12370+
fills = (one, lowered) =>
12371+
keywords.includes(lowered) || (calls && _EASING_CALL_RE.test(one));
12372+
}
12373+
// `none` and `normal` are each a name and a keyword slot's too, so a layer
12374+
// holding one is read by its order, which a fold cannot keep.
12375+
const out = [];
12376+
const at = [];
12377+
for (const one of components) {
12378+
const lowered = toLowerCaseIfNeeded(one);
12379+
if (lowered === "none" || lowered === "normal") return null;
12380+
if (fills(one, lowered)) at.push(out.length);
12381+
out.push(one);
12382+
}
12383+
if (lower === "none" || lower === "normal") return null;
12384+
if (slot === "animation-delay") {
12385+
// A delay is the second time; with no duration the first would be it.
12386+
if (at.length === 0) return null;
12387+
if (at.length === 2) out[at[1]] = value;
12388+
else out.push(value);
12389+
} else if (slot === "animation-duration") {
12390+
if (at.length === 0) out.push(value);
12391+
else out[at[0]] = value;
12392+
} else if (at.length === 0) {
12393+
out.push(value);
12394+
} else if (at.length === 1) {
12395+
out[at[0]] = value;
12396+
} else {
12397+
return null;
12398+
}
12399+
return _joinSlots(out);
12400+
};
12401+
1217212402
// A number with no unit and no percent, which `flex` reads as a factor.
1217312403
const _BARE_NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
1217412404

@@ -12198,6 +12428,58 @@ const _shortestFlexValue = (components) => {
1219812428
: null;
1219912429
};
1220012430

12431+
/**
12432+
* An authored `flex` value of one to three components read into its three
12433+
* factors in grammar order (CSS Flexbox 1 §7.1.1): a bare number is a factor
12434+
* unless two precede it, an omitted shrink is `1` and basis `0%`, and a keyword
12435+
* the factors it names.
12436+
* @param {string[]} components the value's components, as written
12437+
* @returns {string[] | null} grow, shrink and basis, or null where it is no such value
12438+
*/
12439+
const _flexFactors = (components) => {
12440+
if (components.length === 1) {
12441+
const lower = toLowerCaseIfNeeded(components[0]);
12442+
for (const [factors, keyword] of FLEX_KEYWORDS) {
12443+
if (keyword === lower) return factors.split(" ");
12444+
}
12445+
}
12446+
const factors = [];
12447+
let basis = null;
12448+
for (const component of components) {
12449+
if (_BARE_NUMBER_RE.test(component) && factors.length < 2) {
12450+
// The two factors are adjacent: a basis between them parts no pair.
12451+
if (factors.length === 1 && basis !== null && components[0] !== basis) {
12452+
return null;
12453+
}
12454+
factors.push(component);
12455+
} else {
12456+
if (basis !== null) return null;
12457+
basis = component;
12458+
}
12459+
}
12460+
if (factors.length === 0) return null;
12461+
return [
12462+
factors[0],
12463+
factors.length === 2 ? factors[1] : "1",
12464+
basis === null ? "0%" : basis
12465+
];
12466+
};
12467+
12468+
/**
12469+
* The shortest spelling of an authored `flex` value in whatever order and count
12470+
* its grammar allows, or `null` when what was written is already that short.
12471+
* @param {string[]} components the value's components, as written
12472+
* @returns {string[] | null} the fragments to join, or `null` to keep them as they are
12473+
*/
12474+
const _shortestAuthoredFlex = (components) => {
12475+
const factors = _flexFactors(components);
12476+
if (factors === null) return null;
12477+
const shortest = _shortestFlexValue(factors) || factors;
12478+
return shortest.join(" ").length < components.join(" ").length
12479+
? shortest
12480+
: null;
12481+
};
12482+
1220112483
/**
1220212484
* Split on the whitespace at the top of one value, a nested call's own spaces
1220312485
* staying inside it.
@@ -16653,6 +16935,9 @@ module.exports._setRuleEntry = _setRuleEntry;
1665316935
module.exports._setHoistedRuns = _setHoistedRuns;
1665416936
module.exports._shortestColor = _shortestColor;
1665516937
module.exports._shortestFlexValue = _shortestFlexValue;
16938+
module.exports._shortestAuthoredFlex = _shortestAuthoredFlex;
16939+
module.exports._joinSlots = _joinSlots;
16940+
module.exports._splitPrintedComponents = _splitPrintedComponents;
1665616941
module.exports._shorthandSlotsReadable = _shorthandSlotsReadable;
1665716942
module.exports._splitSelectorList = _splitSelectorList;
1665816943
module.exports._splitTopLevelArguments = _splitTopLevelArguments;

0 commit comments

Comments
 (0)