From 220b39039d83d1d5e9c488c21242bede49834a97 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 3 Jan 2026 12:17:47 -0500 Subject: [PATCH 01/33] Implement scroll-state container queries Closes #887, closes #1031 --- node/ast.d.ts | 124 ++++++++++++++++++++++++++++- src/lib.rs | 66 +++++++++++++++ src/media_query.rs | 71 ++++++++++++++--- src/properties/contain.rs | 2 + src/rules/container.rs | 163 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 407 insertions(+), 19 deletions(-) diff --git a/node/ast.d.ts b/node/ast.d.ts index 08d9d786..68730be6 100644 --- a/node/ast.d.ts +++ b/node/ast.d.ts @@ -134,6 +134,10 @@ export type MediaCondition = */ operator: Operator; type: "operation"; + } + | { + type: "unknown"; + value: TokenOrValue[]; }; /** * A generic media feature or container feature. @@ -6420,7 +6424,7 @@ export type ZIndex = /** * A value for the [container-type](https://drafts.csswg.org/css-contain-3/#container-type) property. Establishes the element as a query container for the purpose of container queries. */ -export type ContainerType = "normal" | "inline-size" | "size"; +export type ContainerType = "normal" | "inline-size" | "size" | "scroll-state"; /** * A value for the [container-name](https://drafts.csswg.org/css-contain-3/#container-name) property. */ @@ -6944,6 +6948,25 @@ export type PseudoElement = */ part: ViewTransitionPartSelector; } + | { + /** + * A form control identifier. + */ + identifier: String; + kind: "picker-function"; + } + | { + kind: "picker-icon"; + } + | { + kind: "checkmark"; + } + | { + kind: "grammar-error"; + } + | { + kind: "spelling-error"; + } | { kind: "custom"; /** @@ -7390,6 +7413,14 @@ export type ContainerCondition = | { | { type: "style"; value: StyleQuery; + } +| { + type: "scroll-state"; + value: ScrollStateQuery; + } +| { + type: "unknown"; + value: TokenOrValue[]; }; /** * A generic media feature or container feature. @@ -7485,6 +7516,97 @@ export type StyleQuery = | { operator: Operator; type: "operation"; }; +/** + * Represents a scroll state query within a container condition. + */ +export type ScrollStateQuery = + | { + type: "feature"; + value: QueryFeatureFor_ScrollStateFeatureId; + } + | { + type: "not"; + value: ScrollStateQuery; + } + | { + /** + * The conditions for the operator. + */ + conditions: ScrollStateQuery[]; + /** + * The operator for the conditions. + */ + operator: Operator; + type: "operation"; + }; +/** + * A generic media feature or container feature. + */ +export type QueryFeatureFor_ScrollStateFeatureId = + | { + /** + * The name of the feature. + */ + name: MediaFeatureNameFor_ScrollStateFeatureId; + type: "plain"; + /** + * The feature value. + */ + value: MediaFeatureValue; + } + | { + /** + * The name of the feature. + */ + name: MediaFeatureNameFor_ScrollStateFeatureId; + type: "boolean"; + } + | { + /** + * The name of the feature. + */ + name: MediaFeatureNameFor_ScrollStateFeatureId; + /** + * A comparator. + */ + operator: MediaFeatureComparison; + type: "range"; + /** + * The feature value. + */ + value: MediaFeatureValue; + } + | { + /** + * The end value. + */ + end: MediaFeatureValue; + /** + * A comparator for the end value. + */ + endOperator: MediaFeatureComparison; + /** + * The name of the feature. + */ + name: MediaFeatureNameFor_ScrollStateFeatureId; + /** + * A start value. + */ + start: MediaFeatureValue; + /** + * A comparator for the start value. + */ + startOperator: MediaFeatureComparison; + type: "interval"; + }; +/** + * A media feature name. + */ +export type MediaFeatureNameFor_ScrollStateFeatureId = ScrollStateFeatureId | String | String; +/** + * A container query scroll state feature identifier. + */ +export type ScrollStateFeatureId = "stuck" | "snapped" | "scrollable" | "scrolled"; /** * A property within a `@view-transition` rule. * diff --git a/src/lib.rs b/src/lib.rs index bcd47649..0228e798 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9108,6 +9108,12 @@ mod tests { "@media (prefers-color-scheme = dark) { .foo { color: chartreuse }}", ParserError::InvalidMediaQuery, ); + error_test( + "@media unknown(foo) {}", + ParserError::UnexpectedToken(crate::properties::custom::Token::Function("unknown".into())), + ); + + error_recovery_test("@media unknown(foo) {}"); } #[test] @@ -29335,6 +29341,56 @@ mod tests { "#, "@container style(width){.foo{color:red}}", ); + minify_test( + r#" + @container scroll-state(scrollable: top) { + .foo { + color: red; + } + } + "#, + "@container scroll-state(scrollable:top){.foo{color:red}}", + ); + minify_test( + r#" + @container scroll-state((stuck: top) and (stuck: left)) { + .foo { + color: red; + } + } + "#, + "@container scroll-state((stuck:top) and (stuck:left)){.foo{color:red}}", + ); + minify_test( + r#" + @container scroll-state(not ((scrollable: bottom) and (scrollable: right))) { + .foo { + color: red; + } + } + "#, + "@container scroll-state(not ((scrollable:bottom) and (scrollable:right))){.foo{color:red}}", + ); + minify_test( + r#" + @container (scroll-state(scrollable: inline-end)) { + .foo { + color: red; + } + } + "#, + "@container scroll-state(scrollable:inline-end){.foo{color:red}}", + ); + minify_test( + r#" + @container not scroll-state(scrollable: top) { + .foo { + color: red; + } + } + "#, + "@container not scroll-state(scrollable:top){.foo{color:red}}", + ); // Disallow 'none', 'not', 'and', 'or' as a `` // https://github.com/w3c/csswg-drafts/issues/7203#issuecomment-1144257312 @@ -29379,6 +29435,16 @@ mod tests { "@container style(style(--foo: bar)) {}", ParserError::UnexpectedToken(crate::properties::custom::Token::Function("style".into())), ); + error_test( + "@container scroll-state(scroll-state(scrollable: top)) {}", + ParserError::InvalidMediaQuery, + ); + error_test( + "@container unknown(foo) {}", + ParserError::UnexpectedToken(crate::properties::custom::Token::Function("unknown".into())), + ); + + error_recovery_test("@container unknown(foo) {}"); } #[test] diff --git a/src/media_query.rs b/src/media_query.rs index 1a68d7b5..2b6302a9 100644 --- a/src/media_query.rs +++ b/src/media_query.rs @@ -3,9 +3,9 @@ use crate::error::{ErrorWithLocation, MinifyError, MinifyErrorKind, ParserError, use crate::macros::enum_property; use crate::parser::starts_with_ignore_ascii_case; use crate::printer::Printer; -use crate::properties::custom::EnvironmentVariable; +use crate::properties::custom::{EnvironmentVariable, TokenList}; #[cfg(feature = "visitor")] -use crate::rules::container::ContainerSizeFeatureId; +use crate::rules::container::{ContainerSizeFeatureId, ScrollStateFeatureId}; use crate::rules::custom_media::CustomMediaRule; use crate::rules::Location; use crate::stylesheet::ParserOptions; @@ -534,6 +534,9 @@ pub enum MediaCondition<'i> { /// The conditions for the operator. conditions: Vec>, }, + /// Unknown tokens. + #[cfg_attr(feature = "serde", serde(borrow, with = "ValueWrapper::"))] + Unknown(TokenList<'i>), } /// A trait for conditions such as media queries and container queries. @@ -551,6 +554,13 @@ pub(crate) trait QueryCondition<'i>: Sized { Err(input.new_error_for_next_token()) } + fn parse_scroll_state_query<'t>( + input: &mut Parser<'i, 't>, + _options: &ParserOptions<'_, 'i>, + ) -> Result>> { + Err(input.new_error_for_next_token()) + } + fn needs_parens(&self, parent_operator: Option, targets: &Targets) -> bool; } @@ -579,6 +589,7 @@ impl<'i> QueryCondition<'i> for MediaCondition<'i> { MediaCondition::Not(_) => true, MediaCondition::Operation { operator, .. } => Some(*operator) != parent_operator, MediaCondition::Feature(f) => f.needs_parens(parent_operator, targets), + MediaCondition::Unknown(_) => false, } } } @@ -591,6 +602,8 @@ bitflags! { const ALLOW_OR = 1 << 0; /// Whether to allow style container queries. const ALLOW_STYLE = 1 << 1; + /// Whether to allow scroll state container queries. + const ALLOW_SCROLL_STATE = 1 << 2; } } @@ -601,7 +614,16 @@ impl<'i> MediaCondition<'i> { flags: QueryConditionFlags, options: &ParserOptions<'_, 'i>, ) -> Result>> { - parse_query_condition(input, flags, options) + input + .try_parse(|input| parse_query_condition(input, flags, options)) + .or_else(|e| { + if options.error_recovery { + options.warn(e); + Ok(MediaCondition::Unknown(TokenList::parse(input, options, 0)?)) + } else { + Err(e) + } + }) } fn get_necessary_prefixes(&self, targets: Targets) -> VendorPrefix { @@ -673,28 +695,44 @@ pub(crate) fn parse_query_condition<'t, 'i, P: QueryCondition<'i>>( options: &ParserOptions<'_, 'i>, ) -> Result>> { let location = input.current_source_location(); - let (is_negation, is_style) = match *input.next()? { - Token::ParenthesisBlock => (false, false), - Token::Ident(ref ident) if ident.eq_ignore_ascii_case("not") => (true, false), + enum QueryFunction { + None, + Style, + ScrollState, + } + + let (is_negation, function) = match *input.next()? { + Token::ParenthesisBlock => (false, QueryFunction::None), + Token::Ident(ref ident) if ident.eq_ignore_ascii_case("not") => (true, QueryFunction::None), Token::Function(ref f) if flags.contains(QueryConditionFlags::ALLOW_STYLE) && f.eq_ignore_ascii_case("style") => { - (false, true) + (false, QueryFunction::Style) + } + Token::Function(ref f) + if flags.contains(QueryConditionFlags::ALLOW_SCROLL_STATE) && f.eq_ignore_ascii_case("scroll-state") => + { + (false, QueryFunction::ScrollState) } ref t => return Err(location.new_unexpected_token_error(t.clone())), }; - let first_condition = match (is_negation, is_style) { - (true, false) => { + let first_condition = match (is_negation, function) { + (true, QueryFunction::None) => { let inner_condition = parse_parens_or_function(input, flags, options)?; return Ok(P::create_negation(Box::new(inner_condition))); } - (true, true) => { + (true, QueryFunction::Style) => { let inner_condition = P::parse_style_query(input, options)?; return Ok(P::create_negation(Box::new(inner_condition))); } - (false, false) => parse_paren_block(input, flags, options)?, - (false, true) => P::parse_style_query(input, options)?, + (true, QueryFunction::ScrollState) => { + let inner_condition = P::parse_scroll_state_query(input, options)?; + return Ok(P::create_negation(Box::new(inner_condition))); + } + (false, QueryFunction::None) => parse_paren_block(input, flags, options)?, + (false, QueryFunction::Style) => P::parse_style_query(input, options)?, + (false, QueryFunction::ScrollState) => P::parse_scroll_state_query(input, options)?, }; let operator = match input.try_parse(Operator::parse) { @@ -738,6 +776,11 @@ fn parse_parens_or_function<'t, 'i, P: QueryCondition<'i>>( { P::parse_style_query(input, options) } + Token::Function(ref f) + if flags.contains(QueryConditionFlags::ALLOW_SCROLL_STATE) && f.eq_ignore_ascii_case("scroll-state") => + { + P::parse_scroll_state_query(input, options) + } ref t => return Err(location.new_unexpected_token_error(t.clone())), } } @@ -826,6 +869,7 @@ impl<'i> ToCss for MediaCondition<'i> { ref conditions, operator, } => operation_to_css(operator, conditions, dest), + MediaCondition::Unknown(ref tokens) => tokens.to_css(dest, false), } } } @@ -905,7 +949,8 @@ impl MediaFeatureComparison { feature = "visitor", derive(Visit), visit(visit_media_feature, MEDIA_QUERIES, <'i, MediaFeatureId>), - visit(<'i, ContainerSizeFeatureId>) + visit(<'i, ContainerSizeFeatureId>), + visit(<'i, ScrollStateFeatureId>) )] #[cfg_attr(feature = "into_owned", derive(static_self::IntoOwned))] #[cfg_attr( diff --git a/src/properties/contain.rs b/src/properties/contain.rs index 1c57bab4..27ad2bb9 100644 --- a/src/properties/contain.rs +++ b/src/properties/contain.rs @@ -30,6 +30,8 @@ enum_property! { InlineSize, /// Establishes a query container for container size queries on both the inline and block axis. Size, + /// Establishes a query container for container scroll-state queries + ScrollState, } } diff --git a/src/rules/container.rs b/src/rules/container.rs index a3ac87fe..6eb8aed1 100644 --- a/src/rules/container.rs +++ b/src/rules/container.rs @@ -11,6 +11,7 @@ use crate::media_query::{ }; use crate::parser::{DefaultAtRule, ParserOptions}; use crate::printer::Printer; +use crate::properties::custom::TokenList; use crate::properties::{Property, PropertyId}; #[cfg(feature = "serde")] use crate::serialization::ValueWrapper; @@ -68,6 +69,12 @@ pub enum ContainerCondition<'i> { /// A style query. #[cfg_attr(feature = "serde", serde(borrow, with = "ValueWrapper::"))] Style(StyleQuery<'i>), + /// A scroll state query. + #[cfg_attr(feature = "serde", serde(borrow, with = "ValueWrapper::"))] + ScrollState(ScrollStateQuery<'i>), + /// Unknown tokens. + #[cfg_attr(feature = "serde", serde(borrow, with = "ValueWrapper::"))] + Unknown(TokenList<'i>), } /// A container query size feature. @@ -133,6 +140,61 @@ pub enum StyleQuery<'i> { }, } +/// Represents a scroll state query within a container condition. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "visitor", derive(Visit))] +#[cfg_attr(feature = "into_owned", derive(static_self::IntoOwned))] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(tag = "type", rename_all = "kebab-case") +)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub enum ScrollStateQuery<'i> { + /// A size container feature, implicitly parenthesized. + #[cfg_attr(feature = "serde", serde(borrow, with = "ValueWrapper::"))] + Feature(ScrollStateFeature<'i>), + /// A negation of a condition. + #[cfg_attr(feature = "visitor", skip_type)] + #[cfg_attr(feature = "serde", serde(with = "ValueWrapper::>"))] + Not(Box>), + /// A set of joint operations. + #[cfg_attr(feature = "visitor", skip_type)] + Operation { + /// The operator for the conditions. + operator: Operator, + /// The conditions for the operator. + conditions: Vec>, + }, +} + +/// A container query size feature. +pub type ScrollStateFeature<'i> = QueryFeature<'i, ScrollStateFeatureId>; + +define_query_features! { + /// A container query scroll state feature identifier. + pub enum ScrollStateFeatureId { + /// The [stuck](https://drafts.csswg.org/css-conditional-5/#stuck) scroll state feature. + "stuck": Stuck = Ident, + /// The [snapped](https://drafts.csswg.org/css-conditional-5/#snapped) scroll state feature. + "snapped": Snapped = Ident, + /// The [scrollable](https://drafts.csswg.org/css-conditional-5/#scrollable) scroll state feature. + "scrollable": Scrollable = Ident, + /// The [scrolled](https://drafts.csswg.org/css-conditional-5/#scrolled) scroll state feature. + "scrolled": Scrolled = Ident, + } +} + +impl FeatureToCss for ScrollStateFeatureId { + fn to_css_with_prefix(&self, prefix: &str, dest: &mut Printer) -> Result<(), PrinterError> + where + W: std::fmt::Write, + { + dest.write_str(prefix)?; + self.to_css(dest) + } +} + impl<'i> QueryCondition<'i> for ContainerCondition<'i> { #[inline] fn parse_feature<'t>( @@ -168,12 +230,58 @@ impl<'i> QueryCondition<'i> for ContainerCondition<'i> { }) } + fn parse_scroll_state_query<'t>( + input: &mut Parser<'i, 't>, + options: &ParserOptions<'_, 'i>, + ) -> Result>> { + input.parse_nested_block(|input| { + if let Ok(res) = + input.try_parse(|input| parse_query_condition(input, QueryConditionFlags::ALLOW_OR, options)) + { + return Ok(Self::ScrollState(res)); + } + + Ok(Self::ScrollState(ScrollStateQuery::parse_feature(input, options)?)) + }) + } + fn needs_parens(&self, parent_operator: Option, targets: &Targets) -> bool { match self { ContainerCondition::Not(_) => true, ContainerCondition::Operation { operator, .. } => Some(*operator) != parent_operator, ContainerCondition::Feature(f) => f.needs_parens(parent_operator, targets), ContainerCondition::Style(_) => false, + ContainerCondition::ScrollState(_) => false, + ContainerCondition::Unknown(_) => false, + } + } +} + +impl<'i> QueryCondition<'i> for ScrollStateQuery<'i> { + #[inline] + fn parse_feature<'t>( + input: &mut Parser<'i, 't>, + options: &ParserOptions<'_, 'i>, + ) -> Result>> { + let feature = QueryFeature::parse_with_options(input, options)?; + Ok(Self::Feature(feature)) + } + + #[inline] + fn create_negation(condition: Box) -> Self { + Self::Not(condition) + } + + #[inline] + fn create_operation(operator: Operator, conditions: Vec) -> Self { + Self::Operation { operator, conditions } + } + + fn needs_parens(&self, parent_operator: Option, targets: &Targets) -> bool { + match self { + ScrollStateQuery::Not(_) => true, + ScrollStateQuery::Operation { operator, .. } => Some(*operator) != parent_operator, + ScrollStateQuery::Feature(f) => f.needs_parens(parent_operator, targets), } } } @@ -219,11 +327,24 @@ impl<'i> ParseWithOptions<'i> for ContainerCondition<'i> { input: &mut Parser<'i, 't>, options: &ParserOptions<'_, 'i>, ) -> Result>> { - parse_query_condition( - input, - QueryConditionFlags::ALLOW_OR | QueryConditionFlags::ALLOW_STYLE, - options, - ) + input + .try_parse(|input| { + parse_query_condition( + input, + QueryConditionFlags::ALLOW_OR + | QueryConditionFlags::ALLOW_STYLE + | QueryConditionFlags::ALLOW_SCROLL_STATE, + options, + ) + }) + .or_else(|e| { + if options.error_recovery { + options.warn(e); + Ok(ContainerCondition::Unknown(TokenList::parse(input, options, 0)?)) + } else { + Err(e) + } + }) } } @@ -247,6 +368,19 @@ impl<'i> ToCss for ContainerCondition<'i> { query.to_css(dest)?; dest.write_char(')') } + ContainerCondition::ScrollState(ref query) => { + let needs_parens = !matches!(query, ScrollStateQuery::Feature(_)); + dest.write_str("scroll-state")?; + if needs_parens { + dest.write_char('(')?; + } + query.to_css(dest)?; + if needs_parens { + dest.write_char(')')?; + } + Ok(()) + } + ContainerCondition::Unknown(ref tokens) => tokens.to_css(dest, false), } } } @@ -271,6 +405,25 @@ impl<'i> ToCss for StyleQuery<'i> { } } +impl<'i> ToCss for ScrollStateQuery<'i> { + fn to_css(&self, dest: &mut Printer) -> Result<(), PrinterError> + where + W: std::fmt::Write, + { + match *self { + ScrollStateQuery::Feature(ref f) => f.to_css(dest), + ScrollStateQuery::Not(ref c) => { + dest.write_str("not ")?; + to_css_with_parens_if_needed(&**c, dest, c.needs_parens(None, &dest.targets.current)) + } + ScrollStateQuery::Operation { + ref conditions, + operator, + } => operation_to_css(operator, conditions, dest), + } + } +} + /// A [``](https://drafts.csswg.org/css-contain-3/#typedef-container-name) in a `@container` rule. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] From 28d77942123ed50ab2e0b7bc6e5aaa8e2907564a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 3 Jan 2026 12:33:47 -0500 Subject: [PATCH 02/33] Allow `@property` to be nested inside at-rules (#1090) --- src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/parser.rs | 8 ++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0228e798..24031475 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28748,6 +28748,44 @@ mod tests { "#, "@property --property-name{syntax:\"\";inherits:true;initial-value:#00f}.foo{color:var(--property-name)}", ); + + test( + r#" + @media (width < 800px) { + @property --property-name { + syntax: '*'; + inherits: false; + } + } + "#, + indoc! {r#" + @media (width < 800px) { + @property --property-name { + syntax: "*"; + inherits: false + } + } + "#}, + ); + + test( + r#" + @layer foo { + @property --property-name { + syntax: '*'; + inherits: false; + } + } + "#, + indoc! {r#" + @layer foo { + @property --property-name { + syntax: "*"; + inherits: false + } + } + "#}, + ); } #[test] diff --git a/src/parser.rs b/src/parser.rs index af93d978..f5364797 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -320,10 +320,6 @@ impl<'a, 'o, 'i, T: crate::traits::AtRuleParser<'i>> AtRuleParser<'i> for TopLev let media = MediaList::parse(input, &self.options)?; return Ok(AtRulePrelude::CustomMedia(name, media)) }, - "property" => { - let name = DashedIdent::parse(input)?; - return Ok(AtRulePrelude::Property(name)) - }, _ => {} } @@ -695,6 +691,10 @@ impl<'a, 'o, 'b, 'i, T: crate::traits::AtRuleParser<'i>> AtRuleParser<'i> for Ne return Err(input.new_custom_error(ParserError::DeprecatedCssModulesValueRule)); }, + "property" => { + let name = DashedIdent::parse(input)?; + return Ok(AtRulePrelude::Property(name)) + }, _ => parse_custom_at_rule_prelude(&name, input, self.options, self.at_rule_parser)? }; From c4091ee93d113eedf2e9f8dba88b544728eb0d57 Mon Sep 17 00:00:00 2001 From: Jacob O'Toole Date: Sat, 3 Jan 2026 17:34:39 +0000 Subject: [PATCH 03/33] Ensure compiled range media queries are correctly parenthesised (#1114) Fixes #1105 In the test case added, the range query `(width < 256px)` gets compiled into `not (min-width: 256px)`. If we combine this with another query, for example `(width < 256px) or (hover: none)`, the compiled query should parenthesise the compiled range query. That is, the output should be `(not (min-width: 256px)) or (hover: none)`. Instead, we see an output of `not (min-width: 256px) or (hover: none)`, which incorrectly negates the entire media query, not just the `min-width`. `QueryFeature::needs_parens` determines if parentheses are need with the following logic: ```rust match self { QueryFeature::Interval { .. } => parent_operator != Some(Operator::And), QueryFeature::Range { operator, .. } => { matches!( operator, MediaFeatureComparison::GreaterThan | MediaFeatureComparison::LessThan ) } _ => false, } } ``` This is correct ***if*** both interval and range queries are being compiled, since parentheses are required when the range is replaced with a negation. However, above this return was the block: ```rust if !should_compile!(targets, MediaIntervalSyntax) { return false; } ``` This causes the check to be skipped when `Feature::MediaIntervalSyntax` isn't enabled. This leaves an edge case: if `Feature::MediaRangeSyntax` is enabled, without `Feature::MediaIntervalSyntax`, the correct parentheses check isn't carried out. One would think that this is an unreasonable edge case, since targeting a browser without range syntax support would also be a browser without interval syntax support. However, in `turbopack-css`, the `MediaRangeSyntax` feature is always included, without adding `MediaIntervalSyntax` (see https://github.com/vercel/next.js/commit/a95f8612bcf08dbc52cc4b06e88b43d48f4429e7#diff-389b0ea768c0dbbca95b4aff5d1237ecddb33677521e3e2eb1f717c43c0d4658). In Next 16, the default targets were updated (see https://github.com/vercel/next.js/pull/84401), which caused `MediaIntervalSyntax` to no longer be included by default, hence opening up this edge case. I beleive that, unfortunately, the reason for Next adding `MediaIntervalSyntax` still holds, and applies to intervals too. I'll make a PR there to add `MediaIntervalSyntax` which will avoid this edge case, but it'd be nice to fix it here, too :) This moves the `MediaIntervalSyntax` check into the `Interval` case in the switch, and adds a separate `MediaRangeSyntax` check in the `Range` case. --- src/lib.rs | 32 ++++++++++++++++++++++++++++++++ src/media_query.rs | 17 ++++++++--------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 24031475..6bc99fda 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,13 @@ mod tests { assert_eq!(res.code, expected); } + fn test_with_printer_options<'i, 'o>(source: &'i str, expected: &'i str, options: PrinterOptions<'o>) { + let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); + stylesheet.minify(MinifyOptions::default()).unwrap(); + let res = stylesheet.to_css(options).unwrap(); + assert_eq!(res.code, expected); + } + fn minify_test(source: &str, expected: &str) { minify_test_with_options(source, expected, ParserOptions::default()) } @@ -9064,6 +9071,31 @@ mod tests { }, ); + test_with_printer_options( + r#" + @media (width < 256px) or (hover: none) { + .foo { + color: #fff; + } + } + "#, + indoc! { r#" + @media (not (min-width: 256px)) or (hover: none) { + .foo { + color: #fff; + } + } + "#}, + PrinterOptions { + targets: Targets { + browsers: None, + include: Features::MediaRangeSyntax, + exclude: Features::empty(), + }, + ..Default::default() + }, + ); + error_test( "@media (min-width: hi) { .foo { color: chartreuse }}", ParserError::InvalidMediaQuery, diff --git a/src/media_query.rs b/src/media_query.rs index 2b6302a9..c9eaa4b8 100644 --- a/src/media_query.rs +++ b/src/media_query.rs @@ -1131,17 +1131,16 @@ where } pub(crate) fn needs_parens(&self, parent_operator: Option, targets: &Targets) -> bool { - if !should_compile!(targets, MediaIntervalSyntax) { - return false; - } - match self { - QueryFeature::Interval { .. } => parent_operator != Some(Operator::And), + QueryFeature::Interval { .. } => { + should_compile!(targets, MediaIntervalSyntax) && parent_operator != Some(Operator::And) + } QueryFeature::Range { operator, .. } => { - matches!( - operator, - MediaFeatureComparison::GreaterThan | MediaFeatureComparison::LessThan - ) + should_compile!(targets, MediaRangeSyntax) + && matches!( + operator, + MediaFeatureComparison::GreaterThan | MediaFeatureComparison::LessThan + ) } _ => false, } From a7c5cdc85ab5eda29f690080206e8e93050c2688 Mon Sep 17 00:00:00 2001 From: ion098 <146852218+ion098@users.noreply.github.com> Date: Sat, 3 Jan 2026 09:41:05 -0800 Subject: [PATCH 04/33] fix: add quotes to font-families with multiple consecutive spaces (#1064) --- src/properties/font.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/properties/font.rs b/src/properties/font.rs index 55581d88..875815df 100644 --- a/src/properties/font.rs +++ b/src/properties/font.rs @@ -421,7 +421,13 @@ impl<'i> ToCss for FamilyName<'i> { // https://www.w3.org/TR/css-fonts-4/#family-name-syntax let val = &self.0; if !val.is_empty() && !GenericFontFamily::parse_string(val).is_ok() { - let mut id = String::new(); + // Family names with two or more consecutive spaces must be quoted to preserve the spaces. + let needs_quotes = val.contains(" "); + let mut id = if needs_quotes { + return serialize_string(&val, dest) + } else { + String::new() + }; let mut first = true; for slice in val.split(' ') { if first { From ea294dcbbd5ae3a043604db263f63156da098be6 Mon Sep 17 00:00:00 2001 From: situ2001 Date: Sun, 4 Jan 2026 01:42:47 +0800 Subject: [PATCH 05/33] docs: add toolchain installation guide to CONTRIBUTING (#1040) --- CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8141154..10868f4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,13 @@ yarn wasm:build yarn wasm:build-release ``` +Note: If you plan to build the WASM target, ensure that you have the required toolchain and binaries installed. + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-opt +``` + ## Website The website is built using [Parcel](https://parceljs.org). You can start the development server by running: From 4e6492b893205dfd06e682ed98f535abee6f2971 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 3 Jan 2026 12:45:36 -0500 Subject: [PATCH 06/33] fix compiling --- src/properties/font.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/properties/font.rs b/src/properties/font.rs index 875815df..8b3031a9 100644 --- a/src/properties/font.rs +++ b/src/properties/font.rs @@ -423,11 +423,12 @@ impl<'i> ToCss for FamilyName<'i> { if !val.is_empty() && !GenericFontFamily::parse_string(val).is_ok() { // Family names with two or more consecutive spaces must be quoted to preserve the spaces. let needs_quotes = val.contains(" "); - let mut id = if needs_quotes { - return serialize_string(&val, dest) - } else { - String::new() - }; + if needs_quotes { + serialize_string(&val, dest)?; + return Ok(()); + } + + let mut id = String::new(); let mut first = true; for slice in val.split(' ') { if first { From 32f1f52f025fc43887b20448cc9646b09ef96329 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 3 Jan 2026 14:42:52 -0500 Subject: [PATCH 07/33] Do not remove whitespace in token lists Fixes #1005, fixes #1001, fixes #976, closes #1029, fixes #980, closes #1068, fixes #646, fixes #579 In unknown/custom properties and rules, we do not know what the syntax grammar is, so we don't know where whitespace is significant or not. Previously we tried to be too smart and made some assumptions. This broke certain unknown syntax and also affected custom transformers that tried to replace tokens with other values. We now no longer remove whitespace during parsing, and during printing only replace whitespace with a single space character instead of trying to completely remove it. This results in worse compression in a few places that we can potentially later improve, but is more correct. Note that if you write a custom transformer, you are responsible for ensuring that there are whitespace tokens surrounding any values you replace when needed. Lightning CSS does not automatically insert whitespace as it cannot know whether this is correct or not. But at least now Lightning CSS no longer removes whitespace that was already there. --- node/test/visitor.test.mjs | 62 +++++++++++++++++++ src/lib.rs | 83 ++++++++++++++++++------- src/properties/custom.rs | 122 ++++++------------------------------- src/properties/mod.rs | 5 +- 4 files changed, 145 insertions(+), 127 deletions(-) diff --git a/node/test/visitor.test.mjs b/node/test/visitor.test.mjs index 3a42a696..c763b840 100644 --- a/node/test/visitor.test.mjs +++ b/node/test/visitor.test.mjs @@ -249,6 +249,68 @@ test('specific environment variables', () => { assert.equal(res.code.toString(), '@media (width<=600px){body{padding:20px}}'); }); +test('spacing with env substitution', () => { + // Test spacing for different cases when `env()` functions are replaced with actual values. + /** @type {Record} */ + let tokens = { + '--var1': 'var(--foo)', + '--var2': 'var(--bar)', + '--function': 'scale(1.5)', + '--length1': '10px', + '--length2': '20px', + '--x': '4', + '--y': '12', + '--num1': '5', + '--num2': '10', + '--num3': '15', + '--counter': '2', + '--ident1': 'solid', + '--ident2': 'auto', + '--rotate': '45deg', + '--percentage1': '25%', + '--percentage2': '75%', + '--color': 'red', + '--color1': '#ff1234', + '--string1': '"hello"', + '--string2': '" world"' + }; + + let res = transform({ + filename: 'test.css', + minify: true, + code: Buffer.from(` + .test { + /* Asymmetric spacing - no space after var(). */ + background: env(--var1) env(--var2); + border: env(--var1)env(--ident1); + transform: env(--function) env(--function); + /* Normal spacing between values. */ + padding: env(--length1) env(--length2); + margin: env(--length1) env(--ident2); + outline: env(--color) env(--ident1); + /* Raw numbers that need spacing. */ + cursor: url(cursor.png) env(--x) env(--y), auto; + stroke-dasharray: env(--num1) env(--num2) env(--num3); + counter-increment: myCounter env(--counter); + /* Mixed token types. */ + background: linear-gradient(red env(--percentage1), blue env(--percentage2)); + content: env(--string1) env(--string2); + /* Inside calc expressions. */ + width: calc(env(--length1) - env(--length2)); + } + `), + visitor: { + EnvironmentVariable(env) { + if (env.name.type === 'custom' && tokens[env.name.ident]) { + return { raw: tokens[env.name.ident] }; + } + } + } + }); + + assert.equal(res.code.toString(), '.test{background:var(--foo) var(--bar);border:var(--foo)solid;transform:scale(1.5) scale(1.5);padding:10px 20px;margin:10px auto;outline:red solid;cursor:url(cursor.png) 4 12, auto;stroke-dasharray:5 10 15;counter-increment:myCounter 2;background:linear-gradient(red 25%, blue 75%);content:"hello" " world";width:calc(10px - 20px)}'); +}); + test('url', () => { // https://www.npmjs.com/package/postcss-url let res = transform({ diff --git a/src/lib.rs b/src/lib.rs index 6bc99fda..50b1470f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8020,7 +8020,22 @@ mod tests { ); minify_test( ".foo { width: calc(100% - 2 (2 * var(--card-margin))); }", - ".foo{width:calc(100% - 2 (2*var(--card-margin)))}", + ".foo{width:calc(100% - 2 (2 * var(--card-margin)))}", + ); + + test( + indoc! {r#" + .test { + width: calc(var(--test) + 2px); + width: calc(var(--test) - 2px); + } + "#}, + indoc! {r#" + .test { + width: calc(var(--test) + 2px); + width: calc(var(--test) - 2px); + } + "#}, ); } @@ -8081,7 +8096,7 @@ mod tests { minify_test(".foo { rotate: atan2(0, -1)", ".foo{rotate:180deg}"); minify_test(".foo { rotate: atan2(-1, 1)", ".foo{rotate:-45deg}"); // incompatible units - minify_test(".foo { rotate: atan2(1px, -1vw)", ".foo{rotate:atan2(1px,-1vw)}"); + minify_test(".foo { rotate: atan2(1px, -1vw)", ".foo{rotate:atan2(1px, -1vw)}"); } #[test] @@ -8113,7 +8128,10 @@ mod tests { minify_test(".foo { width: abs(1%)", ".foo{width:abs(1%)}"); // spec says percentages must be against resolved value minify_test(".foo { width: calc(10px * sign(-1vw)", ".foo{width:-10px}"); - minify_test(".foo { width: calc(10px * sign(1%)", ".foo{width:calc(10px*sign(1%))}"); + minify_test( + ".foo { width: calc(10px * sign(1%)", + ".foo{width:calc(10px * sign(1%))}", + ); } #[test] @@ -13947,7 +13965,7 @@ mod tests { // ref: https://github.com/parcel-bundler/lightningcss/pull/255#issuecomment-1219049998 minify_test( "@font-face {src: url(\"foo.ttf\") tech(palettes color-colrv0 variations) format(opentype);}", - "@font-face{src:url(foo.ttf) tech(palettes color-colrv0 variations)format(opentype)}", + "@font-face{src:url(foo.ttf) tech(palettes color-colrv0 variations) format(opentype)}", ); // TODO(CGQAQ): make this test pass when we have strict mode // ref: https://github.com/web-platform-tests/wpt/blob/9f8a6ccc41aa725e8f51f4f096f686313bb88d8d/css/css-fonts/parsing/font-face-src-tech.html#L45 @@ -13969,7 +13987,7 @@ mod tests { // ); minify_test( "@font-face {src: local(\"\") url(\"test.woff\");}", - "@font-face{src:local(\"\")url(test.woff)}", + "@font-face{src:local(\"\") url(test.woff)}", ); minify_test("@font-face {font-weight: 200 400}", "@font-face{font-weight:200 400}"); minify_test("@font-face {font-weight: 400 400}", "@font-face{font-weight:400}"); @@ -14067,7 +14085,7 @@ mod tests { font-family: Handover Sans; base-palette: 3; override-colors: 1 rgb(43, 12, 9), 3 var(--highlight); - }"#, "@font-palette-values --Cooler{font-family:Handover Sans;base-palette:3;override-colors:1 #2b0c09,3 var(--highlight)}"); + }"#, "@font-palette-values --Cooler{font-family:Handover Sans;base-palette:3;override-colors:1 #2b0c09, 3 var(--highlight)}"); prefix_test( r#"@font-palette-values --Cooler { font-family: Handover Sans; @@ -20439,19 +20457,19 @@ mod tests { ); minify_test( ".foo { color: color-mix(in srgb, currentColor, blue); }", - ".foo{color:color-mix(in srgb,currentColor,blue)}", + ".foo{color:color-mix(in srgb, currentColor, blue)}", ); minify_test( ".foo { color: color-mix(in srgb, blue, currentColor); }", - ".foo{color:color-mix(in srgb,blue,currentColor)}", + ".foo{color:color-mix(in srgb, blue, currentColor)}", ); minify_test( ".foo { color: color-mix(in srgb, accentcolor, blue); }", - ".foo{color:color-mix(in srgb,accentcolor,blue)}", + ".foo{color:color-mix(in srgb, accentcolor, blue)}", ); minify_test( ".foo { color: color-mix(in srgb, blue, accentcolor); }", - ".foo{color:color-mix(in srgb,blue,accentcolor)}", + ".foo{color:color-mix(in srgb, blue, accentcolor)}", ); // regex for converting web platform tests: @@ -22645,15 +22663,15 @@ mod tests { minify_test(".foo { --test: var(--foo, 20px); }", ".foo{--test:var(--foo,20px)}"); minify_test( ".foo { transition: var(--foo, 20px),\nvar(--bar, 40px); }", - ".foo{transition:var(--foo,20px),var(--bar,40px)}", + ".foo{transition:var(--foo,20px), var(--bar,40px)}", ); minify_test( ".foo { background: var(--color) var(--image); }", - ".foo{background:var(--color)var(--image)}", + ".foo{background:var(--color) var(--image)}", ); minify_test( ".foo { height: calc(var(--spectrum-global-dimension-size-300) / 2);", - ".foo{height:calc(var(--spectrum-global-dimension-size-300)/2)}", + ".foo{height:calc(var(--spectrum-global-dimension-size-300) / 2)}", ); minify_test( ".foo { color: var(--color, rgb(255, 255, 0)); }", @@ -22665,12 +22683,32 @@ mod tests { ); minify_test( ".foo { color: var(--color, rgb(var(--red), var(--green), 0)); }", - ".foo{color:var(--color,rgb(var(--red),var(--green),0))}", + ".foo{color:var(--color,rgb(var(--red), var(--green), 0))}", ); minify_test(".foo { --test: .5s; }", ".foo{--test:.5s}"); minify_test(".foo { --theme-sizes-1\\/12: 2 }", ".foo{--theme-sizes-1\\/12:2}"); minify_test(".foo { --test: 0px; }", ".foo{--test:0px}"); + // Test attr() function with type() syntax - minified + minify_test( + ".foo { background-color: attr(data-color type()); }", + ".foo{background-color:attr(data-color type())}", + ); + minify_test( + ".foo { width: attr(data-width type(), 100px); }", + ".foo{width:attr(data-width type(), 100px)}", + ); + + // Test attr() function with type() syntax - non-minified (issue with extra spaces) + test( + ".foo { background-color: attr(data-color type()); }", + ".foo {\n background-color: attr(data-color type());\n}\n", + ); + test( + ".foo { width: attr(data-width type(), 100px); }", + ".foo {\n width: attr(data-width type(), 100px);\n}\n", + ); + prefix_test( r#" .foo { @@ -23460,7 +23498,7 @@ mod tests { ); attr_test( "text-decoration: var(--foo) lab(40% 56.6 39);", - "text-decoration:var(--foo)#b32323", + "text-decoration:var(--foo) #b32323", true, Some(Browsers { chrome: Some(90 << 16), @@ -24686,7 +24724,9 @@ mod tests { indoc! {r#" div { color: #00f; - --button: focus { color: red; }; + --button: focus { + color: red; + }; } "#}, ); @@ -29536,11 +29576,12 @@ mod tests { color: red; } }"#, - indoc! {r#" - @foo test { - div { color: red; } - } - "#}, + indoc! { r#"@foo test { + div { + color: red; + } + } + "#}, ); minify_test( r#"@foo test { diff --git a/src/properties/custom.rs b/src/properties/custom.rs index 83a29273..2280b37f 100644 --- a/src/properties/custom.rs +++ b/src/properties/custom.rs @@ -370,59 +370,37 @@ impl<'i> TokenList<'i> { return Err(input.new_custom_error(ParserError::MaximumNestingDepth)); } - let mut last_is_delim = false; - let mut last_is_whitespace = false; loop { let state = input.state(); match input.next_including_whitespace_and_comments() { - Ok(&cssparser::Token::WhiteSpace(..)) | Ok(&cssparser::Token::Comment(..)) => { - // Skip whitespace if the last token was a delimiter. - // Otherwise, replace all whitespace and comments with a single space character. - if !last_is_delim { - tokens.push(Token::WhiteSpace(" ".into()).into()); - last_is_whitespace = true; - } - } Ok(&cssparser::Token::Function(ref f)) => { // Attempt to parse embedded color values into hex tokens. let f = f.into(); if let Some(color) = try_parse_color_token(&f, &state, input) { tokens.push(TokenOrValue::Color(color)); - last_is_delim = false; - last_is_whitespace = false; } else if let Ok(color) = input.try_parse(|input| UnresolvedColor::parse(&f, input, options)) { tokens.push(TokenOrValue::UnresolvedColor(color)); - last_is_delim = true; - last_is_whitespace = false; } else if f == "url" { input.reset(&state); tokens.push(TokenOrValue::Url(Url::parse(input)?)); - last_is_delim = false; - last_is_whitespace = false; } else if f == "var" { let var = input.parse_nested_block(|input| { let var = Variable::parse(input, options, depth + 1)?; Ok(TokenOrValue::Var(var)) })?; tokens.push(var); - last_is_delim = true; - last_is_whitespace = false; } else if f == "env" { let env = input.parse_nested_block(|input| { let env = EnvironmentVariable::parse_nested(input, options, depth + 1)?; Ok(TokenOrValue::Env(env)) })?; tokens.push(env); - last_is_delim = true; - last_is_whitespace = false; } else { let arguments = input.parse_nested_block(|input| TokenList::parse(input, options, depth + 1))?; tokens.push(TokenOrValue::Function(Function { name: Ident(f), arguments, })); - last_is_delim = true; // Whitespace is not required after any of these chars. - last_is_whitespace = false; } } Ok(&cssparser::Token::Hash(ref h)) | Ok(&cssparser::Token::IDHash(ref h)) => { @@ -431,19 +409,13 @@ impl<'i> TokenList<'i> { } else { tokens.push(Token::Hash(h.into()).into()); } - last_is_delim = false; - last_is_whitespace = false; } Ok(&cssparser::Token::UnquotedUrl(_)) => { input.reset(&state); tokens.push(TokenOrValue::Url(Url::parse(input)?)); - last_is_delim = false; - last_is_whitespace = false; } Ok(&cssparser::Token::Ident(ref name)) if name.starts_with("--") => { tokens.push(TokenOrValue::DashedIdent(name.into())); - last_is_delim = false; - last_is_whitespace = false; } Ok(token @ &cssparser::Token::ParenthesisBlock) | Ok(token @ &cssparser::Token::SquareBracketBlock) @@ -459,8 +431,6 @@ impl<'i> TokenList<'i> { input.parse_nested_block(|input| TokenList::parse_into(input, tokens, options, depth + 1))?; tokens.push(closing_delimiter.into()); - last_is_delim = true; // Whitespace is not required after any of these chars. - last_is_whitespace = false; } Ok(token @ cssparser::Token::Dimension { .. }) => { let value = if let Ok(length) = LengthValue::try_from(token) { @@ -475,8 +445,6 @@ impl<'i> TokenList<'i> { TokenOrValue::Token(token.into()) }; tokens.push(value); - last_is_delim = false; - last_is_whitespace = false; } Ok(token) if token.is_parse_error() => { return Err(ParseError { @@ -485,18 +453,7 @@ impl<'i> TokenList<'i> { }) } Ok(token) => { - last_is_delim = matches!(token, cssparser::Token::Delim(_) | cssparser::Token::Comma); - - // If this is a delimiter, and the last token was whitespace, - // replace the whitespace with the delimiter since both are not required. - if last_is_delim && last_is_whitespace { - let last = tokens.last_mut().unwrap(); - *last = Token::from(token).into(); - } else { - tokens.push(Token::from(token).into()); - } - - last_is_whitespace = false; + tokens.push(Token::from(token).into()); } Err(_) => break, } @@ -532,20 +489,13 @@ impl<'i> TokenList<'i> { where W: std::fmt::Write, { - if !dest.minify && self.0.len() == 1 && matches!(self.0.first(), Some(token) if token.is_whitespace()) { - return Ok(()); - } - - let mut has_whitespace = false; - for (i, token_or_value) in self.0.iter().enumerate() { - has_whitespace = match token_or_value { + for token_or_value in self.0.iter() { + match token_or_value { TokenOrValue::Color(color) => { color.to_css(dest)?; - false } TokenOrValue::UnresolvedColor(color) => { color.to_css(dest, is_custom_property)?; - false } TokenOrValue::Url(url) => { if dest.dependencies.is_some() && is_custom_property && !url.is_absolute() { @@ -557,77 +507,45 @@ impl<'i> TokenList<'i> { )); } url.to_css(dest)?; - false } TokenOrValue::Var(var) => { var.to_css(dest, is_custom_property)?; - self.write_whitespace_if_needed(i, dest)? } TokenOrValue::Env(env) => { env.to_css(dest, is_custom_property)?; - self.write_whitespace_if_needed(i, dest)? } TokenOrValue::Function(f) => { f.to_css(dest, is_custom_property)?; - self.write_whitespace_if_needed(i, dest)? } TokenOrValue::Length(v) => { // Do not serialize unitless zero lengths in custom properties as it may break calc(). let (value, unit) = v.to_unit_value(); serialize_dimension(value, unit, dest)?; - false } TokenOrValue::Angle(v) => { v.to_css(dest)?; - false } TokenOrValue::Time(v) => { v.to_css(dest)?; - false } TokenOrValue::Resolution(v) => { v.to_css(dest)?; - false } TokenOrValue::DashedIdent(v) => { v.to_css(dest)?; - false } TokenOrValue::AnimationName(v) => { v.to_css(dest)?; - false } TokenOrValue::Token(token) => match token { - Token::Delim(d) => { - if *d == '+' || *d == '-' { - dest.write_char(' ')?; - dest.write_char(*d)?; - dest.write_char(' ')?; - } else { - let ws_before = !has_whitespace && (*d == '/' || *d == '*'); - dest.delim(*d, ws_before)?; - } - true - } - Token::Comma => { - dest.delim(',', false)?; - true - } - Token::CloseParenthesis | Token::CloseSquareBracket | Token::CloseCurlyBracket => { - token.to_css(dest)?; - self.write_whitespace_if_needed(i, dest)? - } Token::Dimension { value, unit, .. } => { serialize_dimension(*value, unit, dest)?; - false } Token::Number { value, .. } => { value.to_css(dest)?; - false } _ => { token.to_css(dest)?; - matches!(token, Token::WhiteSpace(..)) } }, }; @@ -657,24 +575,8 @@ impl<'i> TokenList<'i> { Ok(()) } - #[inline] - fn write_whitespace_if_needed(&self, i: usize, dest: &mut Printer) -> Result - where - W: std::fmt::Write, - { - if !dest.minify - && i != self.0.len() - 1 - && !matches!( - self.0[i + 1], - TokenOrValue::Token(Token::Comma) | TokenOrValue::Token(Token::CloseParenthesis) - ) - { - // Whitespace is removed during parsing, so add it back if we aren't minifying. - dest.write_char(' ')?; - Ok(true) - } else { - Ok(false) - } + pub(crate) fn starts_with_whitespace(&self) -> bool { + matches!(self.0.get(0), Some(TokenOrValue::Token(Token::WhiteSpace(_)))) } } @@ -986,8 +888,18 @@ impl<'a> ToCss for Token<'a> { int_value: *int_value, } .to_css(dest)?, - Token::WhiteSpace(w) => cssparser::Token::WhiteSpace(w).to_css(dest)?, - Token::Comment(c) => cssparser::Token::Comment(c).to_css(dest)?, + Token::WhiteSpace(w) => { + if dest.minify { + dest.write_char(' ')?; + } else { + dest.write_str(&w)?; + } + } + Token::Comment(c) => { + if !dest.minify { + cssparser::Token::Comment(c).to_css(dest)?; + } + } Token::Colon => cssparser::Token::Colon.to_css(dest)?, Token::Semicolon => cssparser::Token::Semicolon.to_css(dest)?, Token::Comma => cssparser::Token::Comma.to_css(dest)?, diff --git a/src/properties/mod.rs b/src/properties/mod.rs index 5f4424cb..4bc68b01 100644 --- a/src/properties/mod.rs +++ b/src/properties/mod.rs @@ -852,7 +852,10 @@ macro_rules! define_properties { }, Custom(custom) => { custom.name.to_css(dest)?; - dest.delim(':', false)?; + dest.write_char(':')?; + if !custom.value.starts_with_whitespace() { + dest.whitespace()?; + } self.value_to_css(dest)?; write_important!(); return Ok(()) From 8986055badf22d508a197dc8ef368f1a6145f804 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 3 Jan 2026 15:02:10 -0500 Subject: [PATCH 08/33] Fix whitespace handling in view transition pseudos Closes #1087 --- src/lib.rs | 4 ++++ src/selector.rs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 50b1470f..04bbd131 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6972,6 +6972,10 @@ mod tests { &format!(":root::{}(.foo.bar) {{position: fixed}}", name), &format!(":root::{}(.foo.bar){{position:fixed}}", name), ); + minify_test( + &format!(":root::{}( .foo.bar ) {{position: fixed}}", name), + &format!(":root::{}(.foo.bar){{position:fixed}}", name), + ); error_test( &format!(":root::{}(foo):first-child {{position: fixed}}", name), ParserError::SelectorError(SelectorError::InvalidPseudoClassAfterPseudoElement), diff --git a/src/selector.rs b/src/selector.rs index d33d6e51..6b33b7c0 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -1090,10 +1090,14 @@ impl<'i> Parse<'i> for ViewTransitionPartSelector<'i> { _ => return Err(input.new_custom_error(ParserError::SelectorError(SelectorError::InvalidState))), } } else { - return Err(input.new_custom_error(ParserError::SelectorError(SelectorError::InvalidState))); + break; } } + if !input.is_exhausted() || (name.is_none() && classes.is_empty()) { + return Err(input.new_custom_error(ParserError::SelectorError(SelectorError::InvalidState))); + } + Ok(ViewTransitionPartSelector { name, classes }) } } From 9e04c0112da0d614a437ff5903066607c50bc6f9 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sat, 3 Jan 2026 15:12:28 -0500 Subject: [PATCH 09/33] Ensure interleave nested declarations have a semicolon when needed Closes #1089 --- src/lib.rs | 15 +++++++++++++++ src/rules/mod.rs | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 04bbd131..21d32ee8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24929,6 +24929,21 @@ mod tests { exclude: Features::empty(), }, ); + + minify_test( + r#" + .foo { + color: red; + .bar { + color: green; + } + color: blue; + .baz { + color: pink; + } + }"#, + ".foo{color:red;& .bar{color:green}color:#00f;& .baz{color:pink}}", + ); } #[test] diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 9445dca2..5598c96f 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -73,7 +73,7 @@ use crate::printer::Printer; use crate::rules::keyframes::KeyframesName; use crate::selector::{is_compatible, is_equivalent, Component, Selector, SelectorList}; use crate::stylesheet::ParserOptions; -use crate::targets::TargetsWithSupportsScope; +use crate::targets::{should_compile, TargetsWithSupportsScope}; use crate::traits::{AtRuleParser, ToCss}; use crate::values::string::CowArcStr; use crate::vendor_prefix::VendorPrefix; @@ -1025,7 +1025,7 @@ impl<'a, 'i, T: ToCss> ToCss for CssRuleList<'i, T> { let mut first = true; let mut last_without_block = false; - for rule in &self.0 { + for (i, rule) in self.0.iter().enumerate() { if let CssRule::Ignored = &rule { continue; } @@ -1061,6 +1061,16 @@ impl<'a, 'i, T: ToCss> ToCss for CssRuleList<'i, T> { dest.newline()?; } rule.to_css(dest)?; + + // If this is an invisible nested declarations rule, and not the last rule in the block, add a semicolon. + if dest.minify + && !should_compile!(dest.targets.current, Nesting) + && matches!(rule, CssRule::NestedDeclarations(_)) + && i != self.0.len() - 1 + { + dest.write_char(';')?; + } + last_without_block = matches!( rule, CssRule::Import(..) | CssRule::Namespace(..) | CssRule::LayerStatement(..) From bd032104e7f1b6e672443486b952d2126fa04c74 Mon Sep 17 00:00:00 2001 From: Kendell R Date: Sat, 3 Jan 2026 13:17:48 -0800 Subject: [PATCH 10/33] Proper print-color-adjust support (#1102) --- src/lib.rs | 29 +++++++++++++++++++++++++++++ src/properties/mod.rs | 1 + src/properties/prefix_handler.rs | 1 + src/properties/ui.rs | 10 ++++++++++ 4 files changed, 41 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 21d32ee8..c0caf973 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30120,6 +30120,35 @@ mod tests { ); } + #[test] + fn test_print_color_adjust() { + prefix_test( + ".foo { print-color-adjust: exact; }", + indoc! { r#" + .foo { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + "#}, + Browsers { + chrome: Some(135 << 16), + ..Browsers::default() + }, + ); + prefix_test( + ".foo { print-color-adjust: exact; }", + indoc! { r#" + .foo { + print-color-adjust: exact; + } + "#}, + Browsers { + chrome: Some(137 << 16), + ..Browsers::default() + }, + ); + } + #[test] fn test_all() { minify_test(".foo { all: initial; all: initial }", ".foo{all:initial}"); diff --git a/src/properties/mod.rs b/src/properties/mod.rs index 4bc68b01..54c47548 100644 --- a/src/properties/mod.rs +++ b/src/properties/mod.rs @@ -1616,6 +1616,7 @@ define_properties! { // https://drafts.csswg.org/css-color-adjust/ "color-scheme": ColorScheme(ColorScheme), + "print-color-adjust": PrintColorAdjust(PrintColorAdjust, VendorPrefix) / WebKit, } impl<'i, T: smallvec::Array, V: Parse<'i>> Parse<'i> for SmallVec { diff --git a/src/properties/prefix_handler.rs b/src/properties/prefix_handler.rs index 9b7ec78c..b4223477 100644 --- a/src/properties/prefix_handler.rs +++ b/src/properties/prefix_handler.rs @@ -73,6 +73,7 @@ define_prefixes! { ClipPath, BoxDecorationBreak, TextSizeAdjust, + PrintColorAdjust, } macro_rules! define_fallbacks { diff --git a/src/properties/ui.rs b/src/properties/ui.rs index 9b0bb714..06b83ab9 100644 --- a/src/properties/ui.rs +++ b/src/properties/ui.rs @@ -571,6 +571,16 @@ impl<'i> PropertyHandler<'i> for ColorSchemeHandler { fn finalize(&mut self, _: &mut DeclarationList<'i>, _: &mut PropertyHandlerContext<'i, '_>) {} } +enum_property! { + /// A value for the [print-color-adjust](https://drafts.csswg.org/css-color-adjust/#propdef-print-color-adjust) property. + pub enum PrintColorAdjust { + /// The user agent is allowed to make adjustments to the element as it deems appropriate. + Economy, + /// The user agent is not allowed to make adjustments to the element. + Exact, + } +} + #[inline] fn define_var<'i>(name: &'static str, value: Token<'static>) -> Property<'i> { Property::Custom(CustomProperty { From e2a7d52087d4de8e1b6edf739d57da80665ccb5f Mon Sep 17 00:00:00 2001 From: Noah Baldwin <36181524+noahbald@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:55:34 +1100 Subject: [PATCH 11/33] Fix casing for camel-cased svg values (#1108) --- src/lib.rs | 17 +++++++++++++++++ src/properties/svg.rs | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index c0caf973..dc40670a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26403,6 +26403,8 @@ mod tests { #[test] fn test_svg() { + use crate::properties::svg; + minify_test(".foo { fill: yellow; }", ".foo{fill:#ff0}"); minify_test(".foo { fill: url(#foo); }", ".foo{fill:url(#foo)}"); minify_test(".foo { fill: url(#foo) none; }", ".foo{fill:url(#foo) none}"); @@ -27231,6 +27233,21 @@ mod tests { ..Browsers::default() }, ); + + let property = + Property::parse_string("text-rendering".into(), "geometricPrecision", ParserOptions::default()).unwrap(); + assert_eq!( + property, + Property::TextRendering(svg::TextRendering::GeometricPrecision) + ); + let property = + Property::parse_string("shape-rendering".into(), "geometricPrecision", ParserOptions::default()).unwrap(); + assert_eq!( + property, + Property::ShapeRendering(svg::ShapeRendering::GeometricPrecision) + ); + let property = Property::parse_string("color-interpolation".into(), "sRGB", ParserOptions::default()).unwrap(); + assert_eq!(property, Property::ColorInterpolation(svg::ColorInterpolation::SRGB)); } #[test] diff --git a/src/properties/svg.rs b/src/properties/svg.rs index 16b38456..552dd2d2 100644 --- a/src/properties/svg.rs +++ b/src/properties/svg.rs @@ -211,6 +211,7 @@ pub enum Marker<'i> { /// A value for the [color-interpolation](https://www.w3.org/TR/SVG2/painting.html#ColorInterpolation) property. #[derive(Debug, Clone, Copy, PartialEq, Parse, ToCss)] +#[css(case = lower)] #[cfg_attr(feature = "visitor", derive(Visit))] #[cfg_attr( feature = "serde", @@ -249,6 +250,7 @@ pub enum ColorRendering { /// A value for the [shape-rendering](https://www.w3.org/TR/SVG2/painting.html#ShapeRendering) property. #[derive(Debug, Clone, Copy, PartialEq, Parse, ToCss)] +#[css(case = lower)] #[cfg_attr(feature = "visitor", derive(Visit))] #[cfg_attr( feature = "serde", @@ -270,6 +272,7 @@ pub enum ShapeRendering { /// A value for the [text-rendering](https://www.w3.org/TR/SVG2/painting.html#TextRendering) property. #[derive(Debug, Clone, Copy, PartialEq, Parse, ToCss)] +#[css(case = lower)] #[cfg_attr(feature = "visitor", derive(Visit))] #[cfg_attr( feature = "serde", From 203fa3c798a087330a8379d41fa53bf8b89e03c9 Mon Sep 17 00:00:00 2001 From: Andy Li Date: Mon, 19 Jan 2026 21:18:18 -0400 Subject: [PATCH 12/33] feat: support `` in `@property` syntax (#1134) --- src/lib.rs | 11 +++++++++++ src/values/syntax.rs | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index dc40670a..5d3f3f10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28661,6 +28661,17 @@ mod tests { "@property --property-name{syntax:\"\";inherits:true;initial-value:25px}", ); + minify_test( + r#" + @property --property-name { + syntax: ''; + inherits: true; + initial-value: "hi"; + } + "#, + "@property --property-name{syntax:\"\";inherits:true;initial-value:\"hi\"}", + ); + error_test( r#" @property --property-name { diff --git a/src/values/syntax.rs b/src/values/syntax.rs index 42dfe485..306f5e41 100644 --- a/src/values/syntax.rs +++ b/src/values/syntax.rs @@ -63,6 +63,8 @@ pub enum SyntaxComponentKind { Percentage, /// A `` component. LengthPercentage, + /// A `` component. + String, /// A `` component. Color, /// An `` component. @@ -126,6 +128,8 @@ pub enum ParsedComponent<'i> { Percentage(values::percentage::Percentage), /// A `` value. LengthPercentage(values::length::LengthPercentage), + /// A `` value. + String(values::string::CSSString<'i>), /// A `` value. Color(values::color::CssColor), /// An `` value. @@ -222,6 +226,7 @@ impl<'i> SyntaxString { SyntaxComponentKind::LengthPercentage => { ParsedComponent::LengthPercentage(values::length::LengthPercentage::parse(input)?) } + SyntaxComponentKind::String => ParsedComponent::String(values::string::CSSString::parse(input)?), SyntaxComponentKind::Color => ParsedComponent::Color(values::color::CssColor::parse(input)?), SyntaxComponentKind::Image => ParsedComponent::Image(values::image::Image::parse(input)?), SyntaxComponentKind::Url => ParsedComponent::Url(values::url::Url::parse(input)?), @@ -343,6 +348,7 @@ impl SyntaxComponentKind { "number" => SyntaxComponentKind::Number, "percentage" => SyntaxComponentKind::Percentage, "length-percentage" => SyntaxComponentKind::LengthPercentage, + "string" => SyntaxComponentKind::String, "color" => SyntaxComponentKind::Color, "image" => SyntaxComponentKind::Image, "url" => SyntaxComponentKind::Url, @@ -440,6 +446,7 @@ impl ToCss for SyntaxComponentKind { Number => "", Percentage => "", LengthPercentage => "", + String => "", Color => "", Image => "", Url => "", @@ -467,6 +474,7 @@ impl<'i> ToCss for ParsedComponent<'i> { Number(v) => v.to_css(dest), Percentage(v) => v.to_css(dest), LengthPercentage(v) => v.to_css(dest), + String(v) => v.to_css(dest), Color(v) => v.to_css(dest), Image(v) => v.to_css(dest), Url(v) => v.to_css(dest), @@ -640,6 +648,12 @@ mod tests { test("foo | bar | baz", "bar", ParsedComponent::Literal("bar".into())); + test( + "", + "'foo'", + ParsedComponent::String(values::string::CSSString("foo".into())), + ); + test( "", "hi", From a177fd60dbdf6845eeff8155a134e3e2ba58a386 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Jan 2026 18:18:23 -0700 Subject: [PATCH 13/33] feat: add :state() pseudo-class support (#1133) * add :state() psuedo-class support * add linebreak * ensure browser support is accurate --- scripts/build-prefixes.js | 14 +++++++++++ src/compat.rs | 46 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 49 +++++++++++++++++++++++++++++++++++++++ src/selector.rs | 19 ++++++++++++++- 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/scripts/build-prefixes.js b/scripts/build-prefixes.js index 66c862eb..959374e3 100644 --- a/scripts/build-prefixes.js +++ b/scripts/build-prefixes.js @@ -343,6 +343,20 @@ let mdnFeatures = { checkmark: mdn.css.selectors.checkmark.__compat.support, grammarError: mdn.css.selectors['grammar-error'].__compat.support, spellingError: mdn.css.selectors['spelling-error'].__compat.support, + statePseudoClass: Object.fromEntries( + Object.entries(mdn.css.selectors.state.__compat.support) + .map(([browser, value]) => { + // Chrome/Edge 90-124 supported old :--foo syntax which was removed. + // Only include full :state(foo) support from 125+. + if (Array.isArray(value)) { + value = value.filter(v => !v.partial_implementation) + } else if (value.partial_implementation) { + value = undefined; + } + + return [browser, value]; + }) + ), }; for (let key in mdn.css.types.length) { diff --git a/src/compat.rs b/src/compat.rs index d387fba6..2f356efa 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -193,6 +193,7 @@ pub enum Feature { SpaceSeparatedColorNotation, SpellingError, SquareListStyleType, + StatePseudoClass, StretchSize, StringListStyleType, SymbolsListStyleType, @@ -3691,6 +3692,51 @@ impl Feature { return false; } } + Feature::StatePseudoClass => { + if let Some(version) = browsers.chrome { + if version < 8192000 { + return false; + } + } + if let Some(version) = browsers.edge { + if version < 8192000 { + return false; + } + } + if let Some(version) = browsers.firefox { + if version < 8257536 { + return false; + } + } + if let Some(version) = browsers.opera { + if version < 5439488 { + return false; + } + } + if let Some(version) = browsers.safari { + if version < 1115136 { + return false; + } + } + if let Some(version) = browsers.ios_saf { + if version < 1115136 { + return false; + } + } + if let Some(version) = browsers.samsung { + if version < 1769472 { + return false; + } + } + if let Some(version) = browsers.android { + if version < 8192000 { + return false; + } + } + if browsers.ie.is_some() { + return false; + } + } Feature::QUnit => { if let Some(version) = browsers.chrome { if version < 4128768 { diff --git a/src/lib.rs b/src/lib.rs index 5d3f3f10..e541f3ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7009,7 +7009,56 @@ mod tests { ParserError::SelectorError(SelectorError::InvalidState), ); } + minify_test( + "wa-checkbox:state(disabled) {color:red}", + "wa-checkbox:state(disabled){color:red}", + ); + minify_test( + "button:state(checked) {background:blue}", + "button:state(checked){background:#00f}", + ); + minify_test( + "input:state(custom-state) {border:1px solid}", + "input:state(custom-state){border:1px solid}", + ); + minify_test( + "button:active:not(:state(disabled))::part(control) {border:1px solid}", + "button:active:not(:state(disabled))::part(control){border:1px solid}", + ); + // Test nested CSS with :state() selector + nesting_test( + r#" + custom-element { + color: blue; + &:state(loading) { + opacity: 0.5; + & .spinner { + display: block; + } + } + &:state(error) { + border: 2px solid red; + } + } + "#, + indoc! {r#" + custom-element { + color: #00f; + } + custom-element:state(loading) { + opacity: .5; + } + + custom-element:state(loading) .spinner { + display: block; + } + + custom-element:state(error) { + border: 2px solid red; + } + "#}, + ); minify_test(".foo ::deep .bar {width: 20px}", ".foo ::deep .bar{width:20px}"); minify_test(".foo::deep .bar {width: 20px}", ".foo::deep .bar{width:20px}"); minify_test(".foo ::deep.bar {width: 20px}", ".foo ::deep.bar{width:20px}"); diff --git a/src/selector.rs b/src/selector.rs index 6b33b7c0..355ab66e 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -230,6 +230,10 @@ impl<'a, 'o, 'i> parcel_selectors::parser::Parser<'i> for SelectorParser<'a, 'o, let kind = Parse::parse(parser)?; ActiveViewTransitionType { kind } }, + "state" => { + let state = CustomIdent::parse(parser)?; + State { state } + }, "local" if self.options.css_modules.is_some() => Local { selector: Box::new(Selector::parse(self, parser)?) }, "global" if self.options.css_modules.is_some() => Global { selector: Box::new(Selector::parse(self, parser)?) }, _ => { @@ -534,6 +538,12 @@ pub enum PseudoClass<'i> { kind: SmallVec<[CustomIdent<'i>; 1]>, }, + /// The [:state()](https://developer.mozilla.org/en-US/docs/Web/CSS/:state) pseudo class for custom element states. + State { + /// The custom state identifier. + state: CustomIdent<'i>, + }, + // CSS modules /// The CSS modules :local() pseudo class. Local { @@ -676,6 +686,11 @@ where dir.to_css(dest)?; return dest.write_str(")"); } + State { state } => { + dest.write_str(":state(")?; + state.to_css(dest)?; + return dest.write_str(")"); + } _ => {} } @@ -823,7 +838,7 @@ where }) } - Lang { languages: _ } | Dir { direction: _ } => unreachable!(), + Lang { languages: _ } | Dir { direction: _ } | State { .. } => unreachable!(), Custom { name } => { dest.write_char(':')?; return dest.write_str(&name); @@ -1916,6 +1931,8 @@ pub(crate) fn is_compatible(selectors: &[Selector], targets: Targets) -> bool { PseudoClass::Autofill(prefix) if *prefix == VendorPrefix::None => Feature::Autofill, + PseudoClass::State { .. } => Feature::StatePseudoClass, + // Experimental, no browser support. PseudoClass::Current | PseudoClass::Past From 085215e62b19a7c2f388f29b40bf731f1a9fe56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Tue, 20 Jan 2026 11:05:06 +0800 Subject: [PATCH 14/33] feat: improve `grid-template-areas` handling and `grid` shorthand (#1132) - fixed `grid-template-areas` causing unreachable errors. - Add support for grid-auto-flow syntax with grid-template-areas - Handle the case where areas is set but rows/columns is `None` - Pad grid template areas with `.` for missing rows - Add tests for various grid shorthand combinations with areas Fixed: #1096, #1130 --- src/lib.rs | 208 +++++++++++++++++++++++++++++++++++++++++ src/properties/grid.rs | 122 +++++++++++++++++++++++- 2 files changed, 326 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e541f3ff..efa74c7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22037,6 +22037,214 @@ mod tests { ".foo{grid-template-areas:\"head head\"\"nav main\"\". .\"}", ); + // to grid-* shorthand + minify_test( + r#" + .test-miss-areas { + grid-template-columns: 1fr 90px; + grid-template-rows: auto 80px; + grid-template-areas: "one"; + } + "#, + ".test-miss-areas{grid-template:\"one\"\".\"80px/1fr 90px}", + ); + test( + r#" + .test-miss-areas-2 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 30px 60px 100px; + grid-template-areas: "a a a" "b c c"; + } + "#, + indoc! { r#" + .test-miss-areas-2 { + grid-template: "a a a" 30px + "b c c" 60px + ". . ." 100px + / 1fr 1fr 1fr; + } + "#}, + ); + + test( + r#" + .test-miss-areas-3 { + grid-template: 30px 60px 100px / 1fr 1fr 1fr; + grid-template-areas: "a a a" "b c c"; + } + "#, + indoc! { r#" + .test-miss-areas-3 { + grid-template: "a a a" 30px + "b c c" 60px + ". . ." 100px + / 1fr 1fr 1fr; + } + "#}, + ); + + test( + r#" + .test-miss-areas-4 { + grid: 30px 60px 100px / 1fr 1fr 1fr; + grid-template-areas: "a a a" "b c c"; + } + "#, + indoc! { r#" + .test-miss-areas-4 { + grid: "a a a" 30px + "b c c" 60px + ". . ." 100px + / 1fr 1fr 1fr; + } + "#}, + ); + + // test no unreachable error + minify_test( + r#" + .grid-shorthand-areas { + grid: auto / 1fr 3fr; + grid-template-areas: ". content ."; + } + "#, + ".grid-shorthand-areas{grid:\".content.\"/1fr 3fr}", + ); + minify_test( + r#" + .grid-shorthand-areas-rows { + grid: auto / 1fr 3fr; + grid-template-rows: 20px; + grid-template-areas: ". content ."; + } + "#, + ".grid-shorthand-areas-rows{grid:\".content.\"20px/1fr 3fr}", + ); + + // test grid-auto-flow: row in grid shorthand + test( + r#" + .test-auto-flow-row-1 { + grid: auto-flow / 1fr 2fr 1fr; + grid-template-areas: " . one . "; + } + "#, + indoc! { r#" + .test-auto-flow-row-1 { + grid: auto-flow / 1fr 2fr 1fr; + grid-template-areas: ". one ."; + } + "#}, + ); + test( + r#" + .test-auto-flow-row-2 { + grid: auto-flow auto / 100px 100px; + grid-template-areas: " one two "; + } + "#, + indoc! { r#" + .test-auto-flow-row-2 { + grid: auto-flow / 100px 100px; + grid-template-areas: "one two"; + } + "#}, + ); + test( + r#" + .test-auto-flow-dense { + grid: dense auto-flow / 1fr 2fr; + grid-template-areas: " . content . "; + } + "#, + indoc! { r#" + .test-auto-flow-dense { + grid: auto-flow dense / 1fr 2fr; + grid-template-areas: ". content ."; + } + "#}, + ); + minify_test( + r#" + .grid-auto-flow-row-auto-rows { + grid: auto-flow 40px / 1fr 90px; + grid-template-areas: "a"; + } + "#, + ".grid-auto-flow-row-auto-rows{grid:auto-flow 40px/1fr 90px;grid-template-areas:\"a\"}", + ); + minify_test( + r#" + .grid-auto-flow-row-auto-rows-multiple { + grid: auto-flow 40px max-content / 1fr; + grid-template-areas: ". a"; + } + "#, + ".grid-auto-flow-row-auto-rows-multiple{grid:auto-flow 40px max-content/1fr;grid-template-areas:\".a\"}", + ); + + // test grid-auto-flow: column in grid shorthand + test( + r#" + .test-auto-flow-column-1 { + grid: 300px / auto-flow; + grid-template-areas: " . one . "; + } + "#, + indoc! { r#" + .test-auto-flow-column-1 { + grid: 300px / auto-flow; + grid-template-areas: ". one ."; + } + "#}, + ); + test( + r#" + .test-auto-flow-column-2 { + grid: 200px 1fr / auto-flow auto; + grid-template-areas: " . one . "; + } + "#, + indoc! { r#" + .test-auto-flow-column-2 { + grid: 200px 1fr / auto-flow; + grid-template-areas: ". one ."; + } + "#}, + ); + test( + r#" + .test-auto-flow-column-dense { + grid: 1fr 2fr / dense auto-flow; + grid-template-areas: " . content . "; + } + "#, + indoc! { r#" + .test-auto-flow-column-dense { + grid: 1fr 2fr / auto-flow dense; + grid-template-areas: ". content ."; + } + "#}, + ); + minify_test( + r#" + .grid-auto-flow-column-auto-rows { + grid: 1fr 3fr / auto-flow 40px; + grid-template-areas: "a"; + } + "#, + ".grid-auto-flow-column-auto-rows{grid:1fr 3fr/auto-flow 40px;grid-template-areas:\"a\"}", + ); + minify_test( + r#" + .grid-auto-flow-column-auto-rows-multiple { + grid: 1fr / auto-flow 40px max-content ; + grid-template-areas: ". a"; + } + "#, + ".grid-auto-flow-column-auto-rows-multiple{grid:1fr/auto-flow 40px max-content;grid-template-areas:\".a\"}", + ); + test( r#" .foo { diff --git a/src/properties/grid.rs b/src/properties/grid.rs index 6e706319..553cdc54 100644 --- a/src/properties/grid.rs +++ b/src/properties/grid.rs @@ -533,6 +533,7 @@ impl ToCss for TrackSizeList { } /// A value for the [grid-template-areas](https://drafts.csswg.org/css-grid-2/#grid-template-areas-property) property. +/// none | + #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] #[cfg_attr( @@ -704,6 +705,8 @@ impl GridTemplateAreas { /// A value for the [grid-template](https://drafts.csswg.org/css-grid-2/#explicit-grid-shorthand) shorthand property. /// +/// none | [ <'grid-template-rows'> / <'grid-template-columns'> ] | [ ? ? ? ]+ [ / ]? +/// /// If `areas` is not `None`, then `rows` must also not be `None`. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] @@ -935,6 +938,8 @@ impl_shorthand! { bitflags! { /// A value for the [grid-auto-flow](https://drafts.csswg.org/css-grid-2/#grid-auto-flow-property) property. /// + /// [ row | column ] || dense + /// /// The `Row` or `Column` flags may be combined with the `Dense` flag, but the `Row` and `Column` flags may /// not be combined. #[cfg_attr(feature = "visitor", derive(Visit))] @@ -1101,6 +1106,8 @@ impl ToCss for GridAutoFlow { /// A value for the [grid](https://drafts.csswg.org/css-grid-2/#grid-shorthand) shorthand property. /// +/// <'grid-template'> | <'grid-template-rows'> / [ auto-flow && dense? ] <'grid-auto-columns'>? | [ auto-flow && dense? ] <'grid-auto-rows'>? / <'grid-template-columns'> +/// /// Explicit and implicit values may not be combined. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] @@ -1199,6 +1206,41 @@ impl ToCss for Grid<'_> { && self.auto_columns == TrackSizeList::default() && self.auto_flow == GridAutoFlow::default(); + // Handle the case where areas is set but rows is None (auto-flow syntax). + // In this case, output "auto-flow / columns" format. + if self.areas != GridTemplateAreas::None && self.rows == TrackSizing::None { + dest.write_str("auto-flow")?; + if self.auto_flow.contains(GridAutoFlow::Dense) { + dest.write_str(" dense")?; + } + if self.auto_rows != TrackSizeList::default() { + dest.write_char(' ')?; + self.auto_rows.to_css(dest)?; + } + dest.delim('/', true)?; + self.columns.to_css(dest)?; + return Ok(()); + } + + // Handle the case where areas is set but columns is None (auto-flow column syntax). + // In this case, output "rows / auto-flow" format. + if self.areas != GridTemplateAreas::None + && self.columns == TrackSizing::None + && self.auto_flow.direction() == GridAutoFlow::Column + { + self.rows.to_css(dest)?; + dest.delim('/', true)?; + dest.write_str("auto-flow")?; + if self.auto_flow.contains(GridAutoFlow::Dense) { + dest.write_str(" dense")?; + } + if self.auto_columns != TrackSizeList::default() { + dest.write_char(' ')?; + self.auto_columns.to_css(dest)?; + } + return Ok(()); + } + if self.areas != GridTemplateAreas::None || (self.rows != TrackSizing::None && self.columns != TrackSizing::None) || (self.areas == GridTemplateAreas::None && is_auto_initial) @@ -1256,16 +1298,28 @@ impl<'i> Grid<'i> { auto_columns: &TrackSizeList, auto_flow: &GridAutoFlow, ) -> bool { + let default_track_size_list = TrackSizeList::default(); + + // When areas is set but rows is None (auto-flow syntax like "grid: auto-flow / 1fr"), + // we can output the auto-flow shorthand along with "grid-template-areas" separately. + // ⚠️ The case of `grid: 1fr / auto-flow` does not require such handling. + if *areas != GridTemplateAreas::None && *rows == TrackSizing::None { + return auto_flow.direction() == GridAutoFlow::Row; + } + // The `grid` shorthand can either be fully explicit (e.g. same as `grid-template`), // or explicit along a single axis. If there are auto rows, then there cannot be explicit rows, for example. let is_template = GridTemplate::is_valid(rows, columns, areas); - let default_track_size_list = TrackSizeList::default(); let is_explicit = *auto_rows == default_track_size_list && *auto_columns == default_track_size_list && *auto_flow == GridAutoFlow::default(); + // grid-auto-flow: row shorthand syntax: + // [ auto-flow && dense? ] <'grid-auto-rows'>? / <'grid-template-columns'> let is_auto_rows = auto_flow.direction() == GridAutoFlow::Row && *rows == TrackSizing::None && *auto_columns == default_track_size_list; + // grid-auto-flow: column shorthand syntax: + // <'grid-template-rows'> / [ auto-flow && dense? ] <'grid-auto-columns'>? let is_auto_columns = auto_flow.direction() == GridAutoFlow::Column && *columns == TrackSizing::None && *auto_rows == default_track_size_list; @@ -1274,6 +1328,7 @@ impl<'i> Grid<'i> { } } +// TODO: shorthand `grid: auto-flow 1fr / 100px` https://drafts.csswg.org/css-grid/#example-dec34e0f impl_shorthand! { Grid(Grid<'i>) { rows: [GridTemplateRows], @@ -1461,6 +1516,7 @@ macro_rules! impl_grid_placement { define_shorthand! { /// A value for the [grid-row](https://drafts.csswg.org/css-grid-2/#propdef-grid-row) shorthand property. + /// [ / ]? pub struct GridRow<'i> { /// The starting line. #[cfg_attr(feature = "serde", serde(borrow))] @@ -1471,7 +1527,8 @@ define_shorthand! { } define_shorthand! { - /// A value for the [grid-row](https://drafts.csswg.org/css-grid-2/#propdef-grid-column) shorthand property. + /// A value for the [grid-column](https://drafts.csswg.org/css-grid-2/#propdef-grid-column) shorthand property. + /// [ / ]? pub struct GridColumn<'i> { /// The starting line. #[cfg_attr(feature = "serde", serde(borrow))] @@ -1486,6 +1543,7 @@ impl_grid_placement!(GridColumn); define_shorthand! { /// A value for the [grid-area](https://drafts.csswg.org/css-grid-2/#propdef-grid-area) shorthand property. + /// [ / ]{0,3} pub struct GridArea<'i> { /// The grid row start placement. #[cfg_attr(feature = "serde", serde(borrow))] @@ -1684,10 +1742,24 @@ impl<'i> PropertyHandler<'i> for GridHandler<'i> { auto_columns_val, auto_flow_val, ) { + let needs_separate_areas = *areas_val != GridTemplateAreas::None + && ((*rows_val == TrackSizing::None && auto_flow_val.direction() == GridAutoFlow::Row) + || (*columns_val == TrackSizing::None && auto_flow_val.direction() == GridAutoFlow::Column)); + + // Pad areas with "." for missing rows. But don't pad if we're using auto-flow syntax, + // because grid-template-areas should remain as-is in that case. + // Use tuple to avoid double cloning when needs_separate_areas is true. + let (areas_for_grid, areas_for_output) = if needs_separate_areas { + // Take the original areas directly to avoid cloning when needs_separate_areas is true + (areas_val.clone(), Some(areas_val.clone())) + } else { + (GridHandler::pad_grid_template_areas(rows_val, areas_val.clone()), None) + }; + dest.push(Property::Grid(Grid { rows: rows_val.clone(), columns: columns_val.clone(), - areas: areas_val.clone(), + areas: areas_for_grid, auto_rows: auto_rows_val.clone(), auto_columns: auto_columns_val.clone(), auto_flow: auto_flow_val.clone(), @@ -1697,16 +1769,25 @@ impl<'i> PropertyHandler<'i> for GridHandler<'i> { auto_rows = None; auto_columns = None; auto_flow = None; + + // When areas is set but rows/columns is None (auto-flow syntax), also output + // grid-template-areas separately since grid shorthand can't represent this combination. + if let Some(areas) = areas_for_output { + dest.push(Property::GridTemplateAreas(areas)); + } } } // The `grid-template` shorthand supports only explicit track values (i.e. no `repeat()`) // combined with grid-template-areas. If there are no areas, then any track values are allowed. if has_template && GridTemplate::is_valid(rows_val, columns_val, areas_val) { + // Pad areas with "." for missing rows + let padded_areas = GridHandler::pad_grid_template_areas(rows_val, areas_val.clone()); + dest.push(Property::GridTemplate(GridTemplate { rows: rows_val.clone(), columns: columns_val.clone(), - areas: areas_val.clone(), + areas: padded_areas, })); has_template = false; @@ -1763,6 +1844,39 @@ impl<'i> PropertyHandler<'i> for GridHandler<'i> { } } +/// Pads grid template areas with "." (None) for missing rows. +/// All the remaining unnamed areas in a grid can be referred using null cell +/// tokens. A null cell token is a sequence of one or more . (U+002E FULL STOP) +/// characters, e.g., ., ..., or ..... etc. A null cell token can be used to +/// create empty spaces in the grid. +/// Spec: https://drafts.csswg.org/css-grid/#ref-for-string-value① +impl GridHandler<'_> { + fn pad_grid_template_areas(rows: &TrackSizing, areas: GridTemplateAreas) -> GridTemplateAreas { + match (rows, areas) { + (TrackSizing::TrackList(rows_list), GridTemplateAreas::Areas { columns, areas }) => { + let rows_count = rows_list.items.len(); + let areas_rows_count = areas.len() / columns as usize; + if areas_rows_count < rows_count { + let mut padded_areas = areas; + // Fill each missing row with "." (represented as None) + for _ in areas_rows_count..rows_count { + for _ in 0..columns { + padded_areas.push(None); + } + } + GridTemplateAreas::Areas { + columns, + areas: padded_areas, + } + } else { + GridTemplateAreas::Areas { columns, areas } + } + } + (_, areas) => areas, + } + } +} + #[inline] fn is_grid_property(property_id: &PropertyId) -> bool { match property_id { From 9cff098593d1232ecb598fcbbe30a01fe8852560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Tue, 20 Jan 2026 11:12:01 +0800 Subject: [PATCH 15/33] Reduce `min()`, `max()` and `clamp()` with number arguments (#1131) - Add support for `Calc::Number` in reduce_args to simplify `min()/max()/clamp()` with plain number arguments (e.g., `min(1, 2, 3) => min(1, 2)`) - Extend clamp() parsing to handle Number comparisons (e.g., `clamp(0, 255, 300) => 255`) Fixed: #1044, #1129 --- src/lib.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++ src/values/calc.rs | 34 +++++++++---- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index efa74c7b..9be621b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -392,6 +392,122 @@ mod tests { ); } + #[test] + pub fn test_math_fn() { + // max() + minify_test( + r#" + .foo { + color: rgb(max(255, 100), 0, 0); + } + "#, + indoc! {".foo{color:red}" + }, + ); + // min() + minify_test( + r#" + .foo { + color: rgb(min(255, 500), 0, 0); + } + "#, + indoc! {".foo{color:red}" + }, + ); + // abs() + minify_test( + r#" + .foo { + color: rgb(abs(-255), 0, 0); + } + "#, + indoc! {".foo{color:red}" + }, + ); + // clamp() + minify_test( + r#" + .foo { + flex: clamp(1, 5.20, 20); + color: rgb(clamp(0, 255, 300), 0, 0); + } + "#, + indoc! {".foo{color:red;flex:5.2}" + }, + ); + // round() + minify_test( + r#" + .round-color { + color: rgb(round(down, 255.6, 1), 0, 0); + } + "#, + indoc! {".round-color{color:red}" + }, + ); + // hypot() + minify_test( + r#" + .hypot-color { + color: rgb(hypot(255, 0), 0, 0); + } + "#, + indoc! {".hypot-color{color:red}" + }, + ); + // sign(), sign(50) = 1 + minify_test( + r#" + .sign-color { + color: rgb(sign(50), 0, 0); + } + "#, + indoc! {".sign-color{color:#010000}" + }, + ); + // rem(), rem(21, 2) = 1 + minify_test( + r#" + .rem-color { + color: rgb(rem(21, 2), 0, 0); + } + "#, + indoc! {".rem-color{color:#010000}" + }, + ); + // max() in width + minify_test( + r#" + .foo { + width: max(200px, 5px); + } + "#, + indoc! {".foo{width:200px}" + }, + ); + // max() in opacity + minify_test( + r#" + .foo { + opacity: max(1, 0.2); + filter: invert(min(1, 0.5)); + } + "#, + indoc! {".foo{opacity:1;filter:invert(.5)}" + }, + ); + // TODO: support calc in Integer + // minify_test( + // r#" + // .foo { + // z-index: max(100, 20); + // } + // "#, + // indoc! {".foo{z-index:100}" + // }, + // ); + } + #[test] pub fn test_border() { test( diff --git a/src/values/calc.rs b/src/values/calc.rs index 6022cf2f..c57cfb04 100644 --- a/src/values/calc.rs +++ b/src/values/calc.rs @@ -384,10 +384,10 @@ impl< })?; // According to the spec, the minimum should "win" over the maximum if they are in the wrong order. - let cmp = if let (Some(Calc::Value(max_val)), Calc::Value(center_val)) = (&max, ¢er) { - center_val.partial_cmp(&max_val) - } else { - None + let cmp = match (&max, ¢er) { + (Some(Calc::Value(max_val)), Calc::Value(center_val)) => center_val.partial_cmp(&max_val), + (Some(Calc::Number(max_val)), Calc::Number(center_val)) => center_val.partial_cmp(max_val), + _ => None, }; // If center is known to be greater than the maximum, replace it with maximum and remove the max argument. @@ -403,10 +403,10 @@ impl< } if cmp.is_some() { - let cmp = if let (Some(Calc::Value(min_val)), Calc::Value(center_val)) = (&min, ¢er) { - center_val.partial_cmp(&min_val) - } else { - None + let cmp = match (&min, ¢er) { + (Some(Calc::Value(min_val)), Calc::Value(center_val)) => center_val.partial_cmp(&min_val), + (Some(Calc::Number(min_val)), Calc::Number(center_val)) => center_val.partial_cmp(min_val), + _ => None, }; // If center is known to be less than the minimum, replace it with minimum and remove the min argument. @@ -658,6 +658,7 @@ impl< fn reduce_args(args: &mut Vec>, cmp: std::cmp::Ordering) -> Vec> { // Reduces the arguments of a min() or max() expression, combining compatible values. // e.g. min(1px, 1em, 2px, 3in) => min(1px, 1em) + // Also handles plain numbers: min(1, 2, 3) => min(1, 2) let mut reduced: Vec> = vec![]; for arg in args.drain(..) { let mut found = None; @@ -679,6 +680,23 @@ impl< } } } + Calc::Number(val) => { + for b in reduced.iter_mut() { + if let Calc::Number(v) = b { + match val.partial_cmp(v) { + Some(ord) if ord == cmp => { + found = Some(Some(b)); + break; + } + Some(_) => { + found = Some(None); + break; + } + None => {} + } + } + } + } _ => {} } if let Some(r) = found { From 9d04c5d25ea970f4db4c62fa7bdb6010fb365b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Tue, 20 Jan 2026 11:14:48 +0800 Subject: [PATCH 16/33] chore: add more `attr()` test (#1127) Closes: https://github.com/parcel-bundler/lightningcss/issues/1126 --- src/lib.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 9be621b2..3136b306 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23076,6 +23076,34 @@ mod tests { ".foo{width:attr(data-width type(), 100px)}", ); + minify_test( + ".foo { width: attr( data-foo % ); }", + ".foo{width:attr(data-foo %)}", + ); + + // = attr( , ? ) + // Like var(), a bare comma can be used with nothing following it, indicating that the second was passed, just as an empty sequence. + // Spec: https://drafts.csswg.org/css-values-5/#funcdef-attr + minify_test( + ".foo { width: attr( data-foo %, ); }", + ".foo{width:attr(data-foo %,)}", + ); + + minify_test( + ".foo { width: attr( data-foo px ); }", + ".foo{width:attr(data-foo px)}", + ); + + minify_test( + ".foo { width: attr(data-foo number ); }", + ".foo{width:attr(data-foo number)}", + ); + + minify_test( + ".foo { width: attr(data-foo raw-string); }", + ".foo{width:attr(data-foo raw-string)}", + ); + // Test attr() function with type() syntax - non-minified (issue with extra spaces) test( ".foo { background-color: attr(data-color type()); }", From 0e7301a036353cf3d5437a2d24208230a6aecc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Tue, 20 Jan 2026 11:25:55 +0800 Subject: [PATCH 17/33] feat: support name-only `@container` queries (#1125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the CSS specification, `` is now optional. Old: `[ ]? ` New:`[ ? ? ]!` This means that previously, to determine whether an element had container queries enabled, we had to write it like this: ```css @container foo (width >= 0) { .inner { color: green } } ``` It can now be simplified to: ```css @container foo { .inner { color: green } } ``` - Spec: https://github.com/w3c/csswg-drafts/pull/11172 - Chrome implemented in: https://chromium-review.googlesource.com/c/chromium/src/+/7378819 - Safari implemented in: https://bugs.webkit.org/show_bug.cgi?id=302433 ---- This PR also improves error messages; `@container foo () {}` now provides specific syntax error reasons: ``` The brackets cannot be empty ``` --- node/ast.d.ts | 15 ++++++++++++++- src/error.rs | 3 +++ src/lib.rs | 29 +++++++++++++++++++++++++++++ src/media_query.rs | 5 +++++ src/parser.rs | 18 +++++++++++++++--- src/rules/container.rs | 20 +++++++++++++------- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/node/ast.d.ts b/node/ast.d.ts index 68730be6..ae808129 100644 --- a/node/ast.d.ts +++ b/node/ast.d.ts @@ -2367,6 +2367,10 @@ export type PropertyId = | { property: "color-scheme"; } + | { + property: "print-color-adjust"; + vendorPrefix: VendorPrefix; + } | { property: "all"; } @@ -3855,6 +3859,11 @@ export type Declaration = property: "color-scheme"; value: ColorScheme; } + | { + property: "print-color-adjust"; + value: PrintColorAdjust; + vendorPrefix: VendorPrefix; + } | { property: "all"; value: CSSWideKeyword; @@ -6451,6 +6460,10 @@ export type NoneOrCustomIdentList = */ export type ViewTransitionGroup = "normal" | "contain" | "nearest" | String; +/** + * A value for the [print-color-adjust](https://drafts.csswg.org/css-color-adjust/#propdef-print-color-adjust) property. + */ +export type PrintColorAdjust = "economy" | "exact"; /** * A [CSS-wide keyword](https://drafts.csswg.org/css-cascade-5/#defaulting-keywords). */ @@ -9775,7 +9788,7 @@ export interface ContainerRule { /** * The container condition. */ - condition: ContainerCondition; + condition?: ContainerCondition | null; /** * The location of the rule in the source file. */ diff --git a/src/error.rs b/src/error.rs index 4cca1069..b4b1b0ab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -84,6 +84,8 @@ pub enum ParserError<'i> { InvalidDeclaration, /// A media query was invalid. InvalidMediaQuery, + /// The brackets in a condition cannot be empty. + EmptyBracketInCondition, /// Invalid CSS nesting. InvalidNesting, /// The @nest rule is deprecated. @@ -118,6 +120,7 @@ impl<'i> fmt::Display for ParserError<'i> { EndOfInput => write!(f, "Unexpected end of input"), InvalidDeclaration => write!(f, "Invalid declaration"), InvalidMediaQuery => write!(f, "Invalid media query"), + EmptyBracketInCondition => write!(f, "The brackets cannot be empty"), InvalidNesting => write!(f, "Invalid nesting"), DeprecatedNestRule => write!(f, "The @nest rule is deprecated"), DeprecatedCssModulesValueRule => write!(f, "The @value rule is deprecated"), diff --git a/src/lib.rs b/src/lib.rs index 3136b306..19e43f38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9332,6 +9332,10 @@ mod tests { ParserError::UnexpectedToken(crate::properties::custom::Token::Function("unknown".into())), ); + // empty brackets should return a clearer error message + error_test("@media () {}", ParserError::EmptyBracketInCondition); + error_test("@media screen and () {}", ParserError::EmptyBracketInCondition); + error_recovery_test("@media unknown(foo) {}"); } @@ -29569,6 +29573,18 @@ mod tests { #[test] fn test_container_queries() { + // name only (no condition) - new syntax + minify_test( + r#" + @container foo { + .inner { + background: green; + } + } + "#, + "@container foo{.inner{background:green}}", + ); + // with name minify_test( r#" @@ -30002,6 +30018,19 @@ mod tests { ParserError::UnexpectedToken(crate::properties::custom::Token::Function("unknown".into())), ); + // empty container (no name and no condition) should error + error_test("@container {}", ParserError::EndOfInput); + + // empty brackets should return a clearer error message + error_test("@container () {}", ParserError::EmptyBracketInCondition); + + // invalid condition after a name should error + error_test("@container foo () {}", ParserError::EmptyBracketInCondition); + error_test( + "@container foo bar {}", + ParserError::UnexpectedToken(crate::properties::custom::Token::Ident("bar".into())), + ); + error_recovery_test("@container unknown(foo) {}"); } diff --git a/src/media_query.rs b/src/media_query.rs index c9eaa4b8..0a501e7c 100644 --- a/src/media_query.rs +++ b/src/media_query.rs @@ -791,6 +791,11 @@ fn parse_paren_block<'t, 'i, P: QueryCondition<'i>>( options: &ParserOptions<'_, 'i>, ) -> Result>> { input.parse_nested_block(|input| { + // Detect empty brackets and provide a clearer error message. + if input.is_exhausted() { + return Err(input.new_custom_error(ParserError::EmptyBracketInCondition)); + } + if let Ok(inner) = input.try_parse(|i| parse_query_condition(i, flags | QueryConditionFlags::ALLOW_OR, options)) { diff --git a/src/parser.rs b/src/parser.rs index f5364797..0dd76041 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -213,7 +213,9 @@ pub enum AtRulePrelude<'i, T> { /// An @property prelude. Property(DashedIdent<'i>), /// A @container prelude. - Container(Option>, ContainerCondition<'i>), + /// Spec: https://drafts.csswg.org/css-conditional-5/#container-rule + /// @container [ ? ? ]! + Container(Option>, Option>), /// A @starting-style prelude. StartingStyle, /// A @scope rule prelude. @@ -641,8 +643,18 @@ impl<'a, 'o, 'b, 'i, T: crate::traits::AtRuleParser<'i>> AtRuleParser<'i> for Ne }, "container" => { let name = input.try_parse(ContainerName::parse).ok(); - let condition = ContainerCondition::parse_with_options(input, &self.options)?; - AtRulePrelude::Container(name, condition) + match input.try_parse(|input| ContainerCondition::parse_with_options(input, &self.options)) { + Ok(condition) => AtRulePrelude::Container(name, Some(condition)), + Err(e) => { + if name.is_some() && input.is_exhausted() { + // name only, no condition - allowed by new syntax + AtRulePrelude::Container(name, None) + } else { + // condition parsing failed (e.g., empty brackets or invalid tokens) + return Err(e); + } + } + } }, "starting-style" => { AtRulePrelude::StartingStyle diff --git a/src/rules/container.rs b/src/rules/container.rs index 6eb8aed1..97bb1b3a 100644 --- a/src/rules/container.rs +++ b/src/rules/container.rs @@ -32,7 +32,7 @@ pub struct ContainerRule<'i, R = DefaultAtRule> { #[cfg_attr(feature = "serde", serde(borrow))] pub name: Option>, /// The container condition. - pub condition: ContainerCondition<'i>, + pub condition: Option>, /// The rules within the `@container` rule. pub rules: CssRuleList<'i, R>, /// The location of the rule in the source file. @@ -478,16 +478,22 @@ impl<'a, 'i, T: ToCss> ToCss for ContainerRule<'i, T> { #[cfg(feature = "sourcemap")] dest.add_mapping(self.loc); dest.write_str("@container ")?; + let has_condition = self.condition.is_some(); + if let Some(name) = &self.name { name.to_css(dest)?; - dest.write_char(' ')?; + if has_condition { + dest.write_char(' ')?; + } } - // Don't downlevel range syntax in container queries. - let exclude = dest.targets.current.exclude; - dest.targets.current.exclude.insert(Features::MediaQueries); - self.condition.to_css(dest)?; - dest.targets.current.exclude = exclude; + if let Some(condition) = &self.condition { + // Don't downlevel range syntax in container queries. + let exclude = dest.targets.current.exclude; + dest.targets.current.exclude.insert(Features::MediaQueries); + condition.to_css(dest)?; + dest.targets.current.exclude = exclude; + } dest.whitespace()?; dest.write_char('{')?; From e4bcec34e5c0c31b3b252263cce4ac5f1028ed1a Mon Sep 17 00:00:00 2001 From: Jungzl <13jungzl@gmail.com> Date: Tue, 20 Jan 2026 11:32:17 +0800 Subject: [PATCH 18/33] fix: enhance background-position minification and handling for various cases (#1124) --- src/lib.rs | 34 +++++++++++++++++++++++++++++++++- src/values/position.rs | 21 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 19e43f38..c79b4647 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4311,12 +4311,24 @@ mod tests { ); minify_test( ".foo { background-position: left 10px center }", - ".foo{background-position:10px 50%}", + ".foo{background-position:10px}", ); minify_test( ".foo { background-position: right 10px center }", ".foo{background-position:right 10px center}", ); + minify_test( + ".foo { background-position: center top 10px }", + ".foo{background-position:50% 10px}", + ); + minify_test( + ".foo { background-position: center bottom 10px }", + ".foo{background-position:center bottom 10px}", + ); + minify_test( + ".foo { background-position: center 10px }", + ".foo{background-position:50% 10px}", + ); minify_test( ".foo { background-position: right 10px top 20px }", ".foo{background-position:right 10px top 20px}", @@ -4337,6 +4349,26 @@ mod tests { ".foo { background-position: bottom right }", ".foo{background-position:100% 100%}", ); + minify_test( + ".foo { background-position: center top }", + ".foo{background-position:top}", + ); + minify_test( + ".foo { background-position: center bottom }", + ".foo{background-position:bottom}", + ); + minify_test( + ".foo { background-position: left center }", + ".foo{background-position:0}", + ); + minify_test( + ".foo { background-position: right center }", + ".foo{background-position:100%}", + ); + minify_test( + ".foo { background-position: 20px center }", + ".foo{background-position:20px}", + ); minify_test( ".foo { background: url('img-sprite.png') no-repeat bottom right }", diff --git a/src/values/position.rs b/src/values/position.rs index 61373cb8..32e4788d 100644 --- a/src/values/position.rs +++ b/src/values/position.rs @@ -203,11 +203,32 @@ impl ToCss for Position { // `center` is assumed if omitted. x_lp.to_css(dest) } + ( + &HorizontalPosition::Side { + side: HorizontalPositionKeyword::Left, + offset: Some(ref x_lp), + }, + y, + ) if y.is_center() => { + // `left 10px center` => `10px` (omit Y when center) + x_lp.to_css(dest) + } (&HorizontalPosition::Side { side, offset: None }, y) if y.is_center() => { let p: LengthPercentage = side.into(); p.to_css(dest) } (x, y_pos @ &VerticalPosition::Side { offset: None, .. }) if x.is_center() => y_pos.to_css(dest), + ( + &HorizontalPosition::Center, + y_pos @ &VerticalPosition::Side { + side: VerticalPositionKeyword::Bottom, + offset: Some(_), + }, + ) => { + // `center bottom 10px` must keep the keyword form + dest.write_str("center ")?; + y_pos.to_css(dest) + } (&HorizontalPosition::Side { side: x, offset: None }, &VerticalPosition::Side { side: y, offset: None }) => { let x: LengthPercentage = x.into(); let y: LengthPercentage = y.into(); From 7754bd22ee5ad2c2be3b8c10ea3d24ed33d6dad7 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 19 Jan 2026 23:09:49 -0500 Subject: [PATCH 19/33] Update browser compat data --- package.json | 6 +- scripts/build-prefixes.js | 2 +- src/compat.rs | 147 ++++++++++++++++++++++++++------------ src/prefixes.rs | 76 ++++++++++---------- src/properties/size.rs | 6 +- yarn.lock | 87 ++++++++++++---------- 6 files changed, 193 insertions(+), 131 deletions(-) diff --git a/package.json b/package.json index ca020296..d1f7a831 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,10 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lint": "^6.1.0", "@codemirror/theme-one-dark": "^6.1.0", - "@mdn/browser-compat-data": "~7.1.8", + "@mdn/browser-compat-data": "~7.2.4", "@napi-rs/cli": "^2.14.0", - "autoprefixer": "^10.4.21", - "caniuse-lite": "^1.0.30001745", + "autoprefixer": "^10.4.23", + "caniuse-lite": "^1.0.30001765", "codemirror": "^6.0.1", "cssnano": "^7.0.6", "esbuild": "^0.19.8", diff --git a/scripts/build-prefixes.js b/scripts/build-prefixes.js index 959374e3..f44d6374 100644 --- a/scripts/build-prefixes.js +++ b/scripts/build-prefixes.js @@ -404,7 +404,7 @@ for (let key in mdn.css.properties['list-style-type']) { } for (let key in mdn.css.properties['width']) { - if (key === '__compat' || key === 'animatable') { + if (key === '__compat' || key === 'is_animatable') { continue; } diff --git a/src/compat.rs b/src/compat.rs index 2f356efa..cfc7dd59 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -144,7 +144,6 @@ pub enum Feature { MinFunction, ModFunction, MongolianListStyleType, - MozAvailableSize, MyanmarListStyleType, Namespaces, Nesting, @@ -454,7 +453,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -546,7 +545,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -591,7 +590,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -636,7 +635,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -681,7 +680,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -726,7 +725,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -771,7 +770,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -816,7 +815,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -908,7 +907,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -953,7 +952,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1023,7 +1022,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1068,7 +1067,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1158,7 +1157,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1203,7 +1202,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1253,7 +1252,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1340,7 +1339,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1385,7 +1384,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1430,7 +1429,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1475,7 +1474,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1520,7 +1519,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1565,7 +1564,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -1632,7 +1631,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9175040 { + if version < 9371648 { return false; } } @@ -2218,7 +2217,7 @@ impl Feature { } } if let Some(version) = browsers.samsung { - if version < 327680 { + if version < 458752 { return false; } } @@ -2522,6 +2521,16 @@ impl Feature { return false; } } + if let Some(version) = browsers.safari { + if version < 1704448 { + return false; + } + } + if let Some(version) = browsers.ios_saf { + if version < 1704448 { + return false; + } + } if let Some(version) = browsers.samsung { if version < 917504 { return false; @@ -2532,7 +2541,7 @@ impl Feature { return false; } } - if browsers.ie.is_some() || browsers.ios_saf.is_some() || browsers.safari.is_some() { + if browsers.ie.is_some() { return false; } } @@ -3568,12 +3577,17 @@ impl Feature { return false; } } + if let Some(version) = browsers.samsung { + if version < 1900544 { + return false; + } + } if let Some(version) = browsers.android { if version < 8585216 { return false; } } - if browsers.ie.is_some() || browsers.samsung.is_some() { + if browsers.ie.is_some() { return false; } } @@ -3622,7 +3636,7 @@ impl Feature { return false; } } - Feature::Picker | Feature::PickerIcon | Feature::Checkmark => { + Feature::Picker => { if let Some(version) = browsers.chrome { if version < 8847360 { return false; @@ -3638,6 +3652,11 @@ impl Feature { return false; } } + if let Some(version) = browsers.samsung { + if version < 1900544 { + return false; + } + } if let Some(version) = browsers.android { if version < 8847360 { return false; @@ -3647,7 +3666,40 @@ impl Feature { || browsers.ie.is_some() || browsers.ios_saf.is_some() || browsers.safari.is_some() - || browsers.samsung.is_some() + { + return false; + } + } + Feature::PickerIcon | Feature::Checkmark => { + if let Some(version) = browsers.chrome { + if version < 8716288 { + return false; + } + } + if let Some(version) = browsers.edge { + if version < 8716288 { + return false; + } + } + if let Some(version) = browsers.opera { + if version < 5767168 { + return false; + } + } + if let Some(version) = browsers.samsung { + if version < 1900544 { + return false; + } + } + if let Some(version) = browsers.android { + if version < 8716288 { + return false; + } + } + if browsers.firefox.is_some() + || browsers.ie.is_some() + || browsers.ios_saf.is_some() + || browsers.safari.is_some() { return false; } @@ -4118,6 +4170,11 @@ impl Feature { return false; } } + if let Some(version) = browsers.firefox { + if version < 9633792 { + return false; + } + } if let Some(version) = browsers.opera { if version < 5177344 { return false; @@ -4143,7 +4200,7 @@ impl Feature { return false; } } - if browsers.firefox.is_some() || browsers.ie.is_some() { + if browsers.ie.is_some() { return false; } } @@ -4158,6 +4215,11 @@ impl Feature { return false; } } + if let Some(version) = browsers.firefox { + if version < 9633792 { + return false; + } + } if let Some(version) = browsers.opera { if version < 4915200 { return false; @@ -4183,7 +4245,7 @@ impl Feature { return false; } } - if browsers.firefox.is_some() || browsers.ie.is_some() { + if browsers.ie.is_some() { return false; } } @@ -5414,6 +5476,11 @@ impl Feature { return false; } } + if let Some(version) = browsers.firefox { + if version < 9633792 { + return false; + } + } if let Some(version) = browsers.opera { if version < 5439488 { return false; @@ -5439,7 +5506,7 @@ impl Feature { return false; } } - if browsers.firefox.is_some() || browsers.ie.is_some() { + if browsers.ie.is_some() { return false; } } @@ -5655,25 +5722,12 @@ impl Feature { return false; } } - Feature::MozAvailableSize => { + Feature::WebkitFillAvailableSize => { if let Some(version) = browsers.firefox { - if version < 262144 { + if version < 9568256 { return false; } } - if browsers.android.is_some() - || browsers.chrome.is_some() - || browsers.edge.is_some() - || browsers.ie.is_some() - || browsers.ios_saf.is_some() - || browsers.opera.is_some() - || browsers.safari.is_some() - || browsers.samsung.is_some() - { - return false; - } - } - Feature::WebkitFillAvailableSize => { if let Some(version) = browsers.safari { if version < 458752 { return false; @@ -5692,7 +5746,6 @@ impl Feature { if browsers.android.is_some() || browsers.chrome.is_some() || browsers.edge.is_some() - || browsers.firefox.is_some() || browsers.ie.is_some() || browsers.opera.is_some() { diff --git a/src/prefixes.rs b/src/prefixes.rs index ec26658b..63792255 100644 --- a/src/prefixes.rs +++ b/src/prefixes.rs @@ -562,7 +562,7 @@ impl Feature { } Feature::Element => { if let Some(version) = browsers.firefox { - if version >= 131072 && version <= 9043968 { + if version >= 131072 { prefixes |= VendorPrefix::Moz; } } @@ -672,7 +672,7 @@ impl Feature { } } if let Some(version) = browsers.ios_saf { - if version >= 197120 && version <= 1180672 { + if version >= 197120 { prefixes |= VendorPrefix::WebKit; } } @@ -682,7 +682,7 @@ impl Feature { } } if let Some(version) = browsers.safari { - if version >= 196864 && version <= 1180672 { + if version >= 196864 { prefixes |= VendorPrefix::WebKit; } } @@ -1190,17 +1190,17 @@ impl Feature { } Feature::Fill | Feature::FillAvailable => { if let Some(version) = browsers.chrome { - if version >= 1441792 && version <= 8912896 { + if version >= 1441792 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.android { - if version >= 263168 && version <= 8716288 { + if version >= 263168 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.edge { - if version >= 5177344 && version <= 8716288 { + if version >= 5177344 { prefixes |= VendorPrefix::WebKit; } } @@ -1225,7 +1225,7 @@ impl Feature { } } if let Some(version) = browsers.samsung { - if version >= 262144 && version <= 1769472 { + if version >= 262144 { prefixes |= VendorPrefix::WebKit; } } @@ -1268,28 +1268,28 @@ impl Feature { } } Feature::Stretch => { - if let Some(version) = browsers.chrome { - if version >= 1441792 && version <= 8912896 { - prefixes |= VendorPrefix::WebKit; - } - } if let Some(version) = browsers.firefox { - if version >= 196608 && version <= 9043968 { + if version >= 196608 { prefixes |= VendorPrefix::Moz; } } if let Some(version) = browsers.android { - if version >= 263168 && version <= 8716288 { + if version >= 263168 && version <= 263171 { + prefixes |= VendorPrefix::WebKit; + } + } + if let Some(version) = browsers.chrome { + if version >= 1441792 && version <= 8978432 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.edge { - if version >= 5177344 && version <= 8716288 { + if version >= 5177344 && version <= 8978432 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.ios_saf { - if version >= 458752 && version <= 1180672 { + if version >= 458752 { prefixes |= VendorPrefix::WebKit; } } @@ -1299,12 +1299,12 @@ impl Feature { } } if let Some(version) = browsers.safari { - if version >= 458752 && version <= 1180672 { + if version >= 393472 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.samsung { - if version >= 327680 && version <= 1769472 { + if version >= 262144 { prefixes |= VendorPrefix::WebKit; } } @@ -1374,7 +1374,7 @@ impl Feature { } Feature::TextDecorationSkip | Feature::TextDecorationSkipInk => { if let Some(version) = browsers.ios_saf { - if version >= 524288 && version <= 1180672 { + if version >= 524288 { prefixes |= VendorPrefix::WebKit; } } @@ -1386,12 +1386,12 @@ impl Feature { } Feature::TextDecoration => { if let Some(version) = browsers.ios_saf { - if version >= 524288 && version <= 1180672 { + if version >= 524288 && version <= 1704192 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.safari { - if version >= 524288 && version <= 1180672 { + if version >= 524288 && version <= 1704192 { prefixes |= VendorPrefix::WebKit; } } @@ -1414,10 +1414,8 @@ impl Feature { } } Feature::TextSizeAdjust => { - if let Some(version) = browsers.firefox { - if version <= 8847360 { - prefixes |= VendorPrefix::Moz; - } + if browsers.firefox.is_some() { + prefixes |= VendorPrefix::Moz; } if let Some(version) = browsers.edge { if version >= 786432 && version <= 1179648 { @@ -1430,7 +1428,7 @@ impl Feature { } } if let Some(version) = browsers.ios_saf { - if version >= 327680 && version <= 1180672 { + if version >= 327680 { prefixes |= VendorPrefix::WebKit; } } @@ -1534,7 +1532,7 @@ impl Feature { } } if let Some(version) = browsers.ios_saf { - if version >= 458752 && version <= 1180672 { + if version >= 458752 { prefixes |= VendorPrefix::WebKit; } } @@ -1544,12 +1542,12 @@ impl Feature { } } if let Some(version) = browsers.safari { - if version >= 393472 && version <= 1180672 { + if version >= 393472 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.samsung { - if version >= 262144 && version <= 1769472 { + if version >= 262144 { prefixes |= VendorPrefix::WebKit; } } @@ -1921,17 +1919,17 @@ impl Feature { } Feature::CrossFade => { if let Some(version) = browsers.chrome { - if version >= 1114112 && version <= 8912896 { + if version >= 1114112 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.android { - if version >= 263168 && version <= 8716288 { + if version >= 263168 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.edge { - if version >= 5177344 && version <= 8716288 { + if version >= 5177344 { prefixes |= VendorPrefix::WebKit; } } @@ -1951,7 +1949,7 @@ impl Feature { } } if let Some(version) = browsers.samsung { - if version >= 262144 && version <= 1769472 { + if version >= 262144 { prefixes |= VendorPrefix::WebKit; } } @@ -2155,18 +2153,18 @@ impl Feature { } } Feature::PrintColorAdjust | Feature::ColorAdjust => { - if let Some(version) = browsers.chrome { - if version >= 1114112 && version <= 8912896 { + if let Some(version) = browsers.android { + if version >= 263168 && version <= 263171 { prefixes |= VendorPrefix::WebKit; } } - if let Some(version) = browsers.android { - if version >= 263168 && version <= 8716288 { + if let Some(version) = browsers.chrome { + if version >= 1114112 && version <= 8847360 { prefixes |= VendorPrefix::WebKit; } } if let Some(version) = browsers.edge { - if version >= 5177344 && version <= 8716288 { + if version >= 5177344 && version <= 8847360 { prefixes |= VendorPrefix::WebKit; } } @@ -2191,7 +2189,7 @@ impl Feature { } } if let Some(version) = browsers.samsung { - if version >= 262144 && version <= 1769472 { + if version >= 262144 && version <= 1835008 { prefixes |= VendorPrefix::WebKit; } } diff --git a/src/properties/size.rs b/src/properties/size.rs index 0c873357..c6a5eb2e 100644 --- a/src/properties/size.rs +++ b/src/properties/size.rs @@ -144,8 +144,7 @@ impl IsCompatible for Size { } Stretch(vp) => match *vp { VendorPrefix::None => Feature::StretchSize, - VendorPrefix::WebKit => Feature::WebkitFillAvailableSize, - VendorPrefix::Moz => Feature::MozAvailableSize, + VendorPrefix::WebKit | VendorPrefix::Moz => Feature::WebkitFillAvailableSize, _ => return false, } .is_compatible(browsers), @@ -278,8 +277,7 @@ impl IsCompatible for MaxSize { } Stretch(vp) => match *vp { VendorPrefix::None => Feature::StretchSize, - VendorPrefix::WebKit => Feature::WebkitFillAvailableSize, - VendorPrefix::Moz => Feature::MozAvailableSize, + VendorPrefix::WebKit | VendorPrefix::Moz => Feature::WebkitFillAvailableSize, _ => return false, } .is_compatible(browsers), diff --git a/yarn.lock b/yarn.lock index 8ec4f260..2fe9dbf6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -570,10 +570,10 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== -"@mdn/browser-compat-data@~7.1.8": - version "7.1.8" - resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-7.1.8.tgz#504059bb1c27156b0758bc3bcc8b76020c890ed4" - integrity sha512-5ND+azfaE7xtM9vOVzikPu3WtMkCBYvKGY8omIj+zeL+jwuZIJ3g43yuHZvMfmom9wtW6UOB3nNul5oNpMS1kg== +"@mdn/browser-compat-data@~7.2.4": + version "7.2.4" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-7.2.4.tgz#f14464789fc03ba07a19953ec5154b903fcfaa98" + integrity sha512-qlZKXL9qvrxn2UNnlgjupk1sjz0X59oRvGBBaPqYtIxiM0q4m2D5ZF9P/T0qWm0gZYzb5jMZ1TpUViZZVv7cMg== "@mischnic/json-sourcemap@^0.1.0": version "0.1.1" @@ -1505,15 +1505,14 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -autoprefixer@^10.4.21: - version "10.4.21" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" - integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ== +autoprefixer@^10.4.23: + version "10.4.23" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" + integrity sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA== dependencies: - browserslist "^4.24.4" - caniuse-lite "^1.0.30001702" - fraction.js "^4.3.7" - normalize-range "^0.1.2" + browserslist "^4.28.1" + caniuse-lite "^1.0.30001760" + fraction.js "^5.3.4" picocolors "^1.1.1" postcss-value-parser "^4.2.0" @@ -1541,6 +1540,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.9.0: + version "2.9.15" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz#6baaa0069883f50a99cdb31b56646491f47c05d7" + integrity sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg== + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -1580,15 +1584,16 @@ browserslist@^4.0.0, browserslist@^4.23.3, browserslist@^4.6.6: node-releases "^2.0.19" update-browserslist-db "^1.1.1" -browserslist@^4.24.4: - version "4.24.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" - integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== +browserslist@^4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== dependencies: - caniuse-lite "^1.0.30001688" - electron-to-chromium "^1.5.73" - node-releases "^2.0.19" - update-browserslist-db "^1.1.1" + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" buffer-crc32@~0.2.3: version "0.2.13" @@ -1654,15 +1659,10 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001688: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz#f2d15e3aaf8e18f76b2b8c1481abde063b8104c8" integrity sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w== -caniuse-lite@^1.0.30001702: - version "1.0.30001704" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz#6644fe909d924ac3a7125e8a0ab6af95b1f32990" - integrity sha512-+L2IgBbV6gXB4ETf0keSvLr7JUrRVbIaB/lrQ1+z8mRcQiisG5k+lG6O4n6Y5q6f5EuNfaYXKgymucphlEXQew== - -caniuse-lite@^1.0.30001745: - version "1.0.30001745" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz#ab2a36e3b6ed5bfb268adc002c476aab6513f859" - integrity sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ== +caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760, caniuse-lite@^1.0.30001765: + version "1.0.30001765" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz#4a78d8a797fd4124ebaab2043df942eb091648ee" + integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ== chalk@^2.4.2: version "2.4.2" @@ -2072,6 +2072,11 @@ dunder-proto@^1.0.0: es-errors "^1.3.0" gopd "^1.2.0" +electron-to-chromium@^1.5.263: + version "1.5.267" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" + integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== + electron-to-chromium@^1.5.73: version "1.5.75" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz#bba96eabf0e8ca36324679caa38b982800acc87d" @@ -2320,10 +2325,10 @@ formdata-polyfill@^4.0.10: dependencies: fetch-blob "^3.1.2" -fraction.js@^4.3.7: - version "4.3.7" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" - integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== +fraction.js@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" + integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== fs-constants@^1.0.0: version "1.0.0" @@ -3018,10 +3023,10 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== nth-check@^2.0.1: version "2.1.1" @@ -3982,6 +3987,14 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.0" +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From d7f890655ba7bdd257128a1c1b4a013c3b6c3152 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 19 Jan 2026 23:10:40 -0500 Subject: [PATCH 20/33] update ast --- node/ast.d.ts | 26 ++++++++++++++++++++++---- src/lib.rs | 5 +---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/node/ast.d.ts b/node/ast.d.ts index ae808129..28e9d093 100644 --- a/node/ast.d.ts +++ b/node/ast.d.ts @@ -5231,7 +5231,7 @@ export type RepeatCount = }; export type AutoFlowDirection = "row" | "column"; /** - * A value for the [grid-template-areas](https://drafts.csswg.org/css-grid-2/#grid-template-areas-property) property. + * A value for the [grid-template-areas](https://drafts.csswg.org/css-grid-2/#grid-template-areas-property) property. none | + */ export type GridTemplateAreas = | { @@ -6801,6 +6801,13 @@ export type PseudoClass = */ type: String[]; } + | { + kind: "state"; + /** + * The custom state identifier. + */ + state: String; + } | { kind: "local"; /** @@ -7266,6 +7273,10 @@ export type ParsedComponent = type: "length-percentage"; value: DimensionPercentageFor_LengthValue; } + | { + type: "string"; + value: String; + } | { type: "color"; value: CssColor; @@ -7367,6 +7378,9 @@ export type SyntaxComponentKind = | { type: "length-percentage"; } + | { + type: "string"; + } | { type: "color"; } @@ -8493,6 +8507,8 @@ export interface GridAutoFlow { /** * A value for the [grid-template](https://drafts.csswg.org/css-grid-2/#explicit-grid-shorthand) shorthand property. * + * none | [ <'grid-template-rows'> / <'grid-template-columns'> ] | [ ? ? ? ]+ [ / ]? + * * If `areas` is not `None`, then `rows` must also not be `None`. */ export interface GridTemplate { @@ -8512,6 +8528,8 @@ export interface GridTemplate { /** * A value for the [grid](https://drafts.csswg.org/css-grid-2/#grid-shorthand) shorthand property. * + * <'grid-template'> | <'grid-template-rows'> / [ auto-flow && dense? ] <'grid-auto-columns'>? | [ auto-flow && dense? ] <'grid-auto-rows'>? / <'grid-template-columns'> + * * Explicit and implicit values may not be combined. */ export interface Grid { @@ -8541,7 +8559,7 @@ export interface Grid { rows: TrackSizing; } /** - * A value for the [grid-row](https://drafts.csswg.org/css-grid-2/#propdef-grid-row) shorthand property. + * A value for the [grid-row](https://drafts.csswg.org/css-grid-2/#propdef-grid-row) shorthand property. [ / ]? */ export interface GridRow { /** @@ -8554,7 +8572,7 @@ export interface GridRow { start: GridLine; } /** - * A value for the [grid-row](https://drafts.csswg.org/css-grid-2/#propdef-grid-column) shorthand property. + * A value for the [grid-column](https://drafts.csswg.org/css-grid-2/#propdef-grid-column) shorthand property. [ / ]? */ export interface GridColumn { /** @@ -8567,7 +8585,7 @@ export interface GridColumn { start: GridLine; } /** - * A value for the [grid-area](https://drafts.csswg.org/css-grid-2/#propdef-grid-area) shorthand property. + * A value for the [grid-area](https://drafts.csswg.org/css-grid-2/#propdef-grid-area) shorthand property. [ / ]{0,3} */ export interface GridArea { /** diff --git a/src/lib.rs b/src/lib.rs index c79b4647..4b0b8539 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23112,10 +23112,7 @@ mod tests { ".foo{width:attr(data-width type(), 100px)}", ); - minify_test( - ".foo { width: attr( data-foo % ); }", - ".foo{width:attr(data-foo %)}", - ); + minify_test(".foo { width: attr( data-foo % ); }", ".foo{width:attr(data-foo %)}"); // = attr( , ? ) // Like var(), a bare comma can be used with nothing following it, indicating that the second was passed, just as an empty sequence. From b0589167e7db9882b3e2af9a589c988a80ba7e12 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 19 Jan 2026 23:29:44 -0500 Subject: [PATCH 21/33] v1.31.0 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- napi/Cargo.toml | 4 ++-- node/Cargo.toml | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01ce765f..17ef357e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "lightningcss" -version = "1.0.0-alpha.68" +version = "1.0.0-alpha.69" dependencies = [ "ahash 0.8.12", "assert_cmd", @@ -802,7 +802,7 @@ dependencies = [ [[package]] name = "lightningcss-napi" -version = "0.4.5" +version = "0.4.6" dependencies = [ "crossbeam-channel", "cssparser", diff --git a/Cargo.toml b/Cargo.toml index b3252cd9..37afa4bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ [package] authors = ["Devon Govett "] name = "lightningcss" -version = "1.0.0-alpha.68" +version = "1.0.0-alpha.69" description = "A CSS parser, transformer, and minifier" license = "MPL-2.0" edition = "2021" diff --git a/napi/Cargo.toml b/napi/Cargo.toml index 2e2b8da2..da056b1a 100644 --- a/napi/Cargo.toml +++ b/napi/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Devon Govett "] name = "lightningcss-napi" -version = "0.4.5" +version = "0.4.6" description = "Node-API bindings for Lightning CSS" license = "MPL-2.0" repository = "https://github.com/parcel-bundler/lightningcss" @@ -17,7 +17,7 @@ serde = { version = "1.0.201", features = ["derive"] } serde-content = { version = "0.1.2", features = ["serde"] } serde_bytes = "0.11.5" cssparser = "0.33.0" -lightningcss = { version = "1.0.0-alpha.68", path = "../", features = [ +lightningcss = { version = "1.0.0-alpha.69", path = "../", features = [ "nodejs", "serde", ] } diff --git a/node/Cargo.toml b/node/Cargo.toml index 02823cf0..c38634fe 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -lightningcss-napi = { version = "0.4.4", path = "../napi", features = [ +lightningcss-napi = { version = "0.4.6", path = "../napi", features = [ "bundler", "visitor", ] } diff --git a/package.json b/package.json index d1f7a831..a301c10d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lightningcss", - "version": "1.30.2", + "version": "1.31.0", "license": "MPL-2.0", "description": "A CSS parser, transformer, and minifier written in Rust", "main": "node/index.js", From c01c1619f109aa9d089d63fd9d025818eaaa083e Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Jan 2026 00:11:09 -0500 Subject: [PATCH 22/33] update rust toolchain --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 80afd2d3..1a216558 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.83.0" +channel = "1.92.0" components = ["rustfmt", "clippy"] From 7829bf9a7ff791dac3ff1aa379772e17df428b9a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Jan 2026 00:42:32 -0500 Subject: [PATCH 23/33] fixup --- selectors/parser.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/selectors/parser.rs b/selectors/parser.rs index d79365b4..19563d5f 100644 --- a/selectors/parser.rs +++ b/selectors/parser.rs @@ -880,12 +880,12 @@ impl<'i, Impl: SelectorImpl<'i>> Selector<'i, Impl> { /// Returns an iterator over the entire sequence of simple selectors and /// combinators, in matching order (from right to left). #[inline] - pub fn iter_raw_match_order(&self) -> slice::Iter> { + pub fn iter_raw_match_order(&self) -> slice::Iter<'_, Component<'i, Impl>> { self.1.iter() } #[inline] - pub fn iter_mut_raw_match_order(&mut self) -> slice::IterMut> { + pub fn iter_mut_raw_match_order(&mut self) -> slice::IterMut<'_, Component<'i, Impl>> { self.1.iter_mut() } @@ -903,7 +903,7 @@ impl<'i, Impl: SelectorImpl<'i>> Selector<'i, Impl> { /// combinators, in parse order (from left to right), starting from /// `offset`. #[inline] - pub fn iter_raw_parse_order_from(&self, offset: usize) -> Rev>> { + pub fn iter_raw_parse_order_from(&self, offset: usize) -> Rev>> { self.1[..self.len() - offset].iter().rev() } From d0d7f04b020b72266cf6a03427d978e9083bb525 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Jan 2026 00:54:41 -0500 Subject: [PATCH 24/33] more fixup --- scripts/build-prefixes.js | 5 ++- src/compat.rs | 93 +++++++++++++++------------------------ src/media_query.rs | 2 +- src/properties/custom.rs | 2 +- 4 files changed, 42 insertions(+), 60 deletions(-) diff --git a/scripts/build-prefixes.js b/scripts/build-prefixes.js index f44d6374..3a9f7945 100644 --- a/scripts/build-prefixes.js +++ b/scripts/build-prefixes.js @@ -665,7 +665,10 @@ impl Feature { if self.is_compatible(browsers) { return true } - browsers.${browser} = None; + #[allow(unused_assignments)] + { + browsers.${browser} = None; + } }\n`).join(' ')} false } diff --git a/src/compat.rs b/src/compat.rs index cfc7dd59..f0c828d7 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -100,7 +100,6 @@ pub enum Feature { ImageSet, InOutOfRange, IndeterminatePseudo, - IsAnimatableSize, IsSelector, JapaneseFormalListStyleType, JapaneseInformalListStyleType, @@ -5555,53 +5554,6 @@ impl Feature { return false; } } - Feature::IsAnimatableSize => { - if let Some(version) = browsers.chrome { - if version < 1703936 { - return false; - } - } - if let Some(version) = browsers.edge { - if version < 786432 { - return false; - } - } - if let Some(version) = browsers.firefox { - if version < 1048576 { - return false; - } - } - if let Some(version) = browsers.ie { - if version < 720896 { - return false; - } - } - if let Some(version) = browsers.opera { - if version < 917504 { - return false; - } - } - if let Some(version) = browsers.safari { - if version < 458752 { - return false; - } - } - if let Some(version) = browsers.ios_saf { - if version < 458752 { - return false; - } - } - if let Some(version) = browsers.samsung { - if version < 66816 { - return false; - } - } - if let Some(version) = browsers.android { - if version < 263168 { - return false; - } - } - } Feature::MaxContentSize => { if let Some(version) = browsers.chrome { if version < 1638400 { @@ -5785,63 +5737,90 @@ impl Feature { if self.is_compatible(browsers) { return true; } - browsers.android = None; + #[allow(unused_assignments)] + { + browsers.android = None; + } } if targets.chrome.is_some() { browsers.chrome = targets.chrome; if self.is_compatible(browsers) { return true; } - browsers.chrome = None; + #[allow(unused_assignments)] + { + browsers.chrome = None; + } } if targets.edge.is_some() { browsers.edge = targets.edge; if self.is_compatible(browsers) { return true; } - browsers.edge = None; + #[allow(unused_assignments)] + { + browsers.edge = None; + } } if targets.firefox.is_some() { browsers.firefox = targets.firefox; if self.is_compatible(browsers) { return true; } - browsers.firefox = None; + #[allow(unused_assignments)] + { + browsers.firefox = None; + } } if targets.ie.is_some() { browsers.ie = targets.ie; if self.is_compatible(browsers) { return true; } - browsers.ie = None; + #[allow(unused_assignments)] + { + browsers.ie = None; + } } if targets.ios_saf.is_some() { browsers.ios_saf = targets.ios_saf; if self.is_compatible(browsers) { return true; } - browsers.ios_saf = None; + #[allow(unused_assignments)] + { + browsers.ios_saf = None; + } } if targets.opera.is_some() { browsers.opera = targets.opera; if self.is_compatible(browsers) { return true; } - browsers.opera = None; + #[allow(unused_assignments)] + { + browsers.opera = None; + } } if targets.safari.is_some() { browsers.safari = targets.safari; if self.is_compatible(browsers) { return true; } - browsers.safari = None; + #[allow(unused_assignments)] + { + browsers.safari = None; + } } if targets.samsung.is_some() { browsers.samsung = targets.samsung; if self.is_compatible(browsers) { return true; } - browsers.samsung = None; + #[allow(unused_assignments)] + { + browsers.samsung = None; + } } false diff --git a/src/media_query.rs b/src/media_query.rs index 0a501e7c..df4b4cf3 100644 --- a/src/media_query.rs +++ b/src/media_query.rs @@ -1893,7 +1893,7 @@ mod tests { targets::{Browsers, Targets}, }; - fn parse(s: &str) -> MediaQuery { + fn parse(s: &str) -> MediaQuery<'_> { let mut input = ParserInput::new(&s); let mut parser = Parser::new(&mut input); MediaQuery::parse_with_options(&mut parser, &ParserOptions::default()).unwrap() diff --git a/src/properties/custom.rs b/src/properties/custom.rs index 2280b37f..46a3858d 100644 --- a/src/properties/custom.rs +++ b/src/properties/custom.rs @@ -993,7 +993,7 @@ impl<'a> std::hash::Hash for Token<'a> { /// Converts a floating point value into its mantissa, exponent, /// and sign components so that it can be hashed. fn integer_decode(v: f32) -> (u32, i16, i8) { - let bits: u32 = unsafe { std::mem::transmute(v) }; + let bits: u32 = f32::to_bits(v); let sign: i8 = if bits >> 31 == 0 { 1 } else { -1 }; let mut exponent: i16 = ((bits >> 23) & 0xff) as i16; let mantissa = if exponent == 0 { From f3666d64fea0d0013317ed3748b9a75ab7cacea1 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Jan 2026 20:33:25 -0500 Subject: [PATCH 25/33] Fix regression parsing import rules Fixes #1137 --- src/lib.rs | 29 +++++++++++++++++------------ src/media_query.rs | 4 ++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4b0b8539..86f3d0df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -244,20 +244,23 @@ mod tests { } } - fn error_recovery_test(source: &str) { + fn error_recovery_test(source: &str) -> Vec>> { let warnings = Arc::new(RwLock::default()); - let res = StyleSheet::parse( - &source, - ParserOptions { - error_recovery: true, - warnings: Some(warnings.clone()), - ..Default::default() - }, - ); - match res { - Ok(..) => {} - Err(e) => unreachable!("parser error should be recovered, but got {e:?}"), + { + let res = StyleSheet::parse( + &source, + ParserOptions { + error_recovery: true, + warnings: Some(warnings.clone()), + ..Default::default() + }, + ); + match res { + Ok(..) => {} + Err(e) => unreachable!("parser error should be recovered, but got {e:?}"), + } } + Arc::into_inner(warnings).unwrap().into_inner().unwrap() } fn css_modules_error_test(source: &str, error: ParserError) { @@ -15062,6 +15065,8 @@ mod tests { "@layer foo; @import url(foo.css); @layer bar; @import url(bar.css)", ParserError::UnexpectedImportRule, ); + let warnings = error_recovery_test("@import './actual-styles.css';"); + assert_eq!(warnings, vec![]); } #[test] diff --git a/src/media_query.rs b/src/media_query.rs index df4b4cf3..2f0ee9de 100644 --- a/src/media_query.rs +++ b/src/media_query.rs @@ -56,6 +56,10 @@ impl<'i> MediaList<'i> { options: &ParserOptions<'_, 'i>, ) -> Result>> { let mut media_queries = vec![]; + if input.is_exhausted() { + return Ok(MediaList { media_queries }); + } + loop { match input.parse_until_before(Delimiter::Comma, |i| MediaQuery::parse_with_options(i, options)) { Ok(mq) => { From 6993d9f1d3cd69030c5976cd8860361b7679f68d Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Jan 2026 21:56:20 -0500 Subject: [PATCH 26/33] v1.31.1 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- napi/Cargo.toml | 4 ++-- node/Cargo.toml | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17ef357e..bb4bb284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "lightningcss" -version = "1.0.0-alpha.69" +version = "1.0.0-alpha.70" dependencies = [ "ahash 0.8.12", "assert_cmd", @@ -802,7 +802,7 @@ dependencies = [ [[package]] name = "lightningcss-napi" -version = "0.4.6" +version = "0.4.7" dependencies = [ "crossbeam-channel", "cssparser", diff --git a/Cargo.toml b/Cargo.toml index 37afa4bb..c113a392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ [package] authors = ["Devon Govett "] name = "lightningcss" -version = "1.0.0-alpha.69" +version = "1.0.0-alpha.70" description = "A CSS parser, transformer, and minifier" license = "MPL-2.0" edition = "2021" diff --git a/napi/Cargo.toml b/napi/Cargo.toml index da056b1a..789062ea 100644 --- a/napi/Cargo.toml +++ b/napi/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Devon Govett "] name = "lightningcss-napi" -version = "0.4.6" +version = "0.4.7" description = "Node-API bindings for Lightning CSS" license = "MPL-2.0" repository = "https://github.com/parcel-bundler/lightningcss" @@ -17,7 +17,7 @@ serde = { version = "1.0.201", features = ["derive"] } serde-content = { version = "0.1.2", features = ["serde"] } serde_bytes = "0.11.5" cssparser = "0.33.0" -lightningcss = { version = "1.0.0-alpha.69", path = "../", features = [ +lightningcss = { version = "1.0.0-alpha.70", path = "../", features = [ "nodejs", "serde", ] } diff --git a/node/Cargo.toml b/node/Cargo.toml index c38634fe..b5c7505c 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -lightningcss-napi = { version = "0.4.6", path = "../napi", features = [ +lightningcss-napi = { version = "0.4.7", path = "../napi", features = [ "bundler", "visitor", ] } diff --git a/package.json b/package.json index a301c10d..af17c0a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lightningcss", - "version": "1.31.0", + "version": "1.31.1", "license": "MPL-2.0", "description": "A CSS parser, transformer, and minifier written in Rust", "main": "node/index.js", From aa2ed1e3179f4292047608df604ef4c12bab3f80 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 28 Jan 2026 20:17:22 +0100 Subject: [PATCH 27/33] Fix additionally inserted whitespace in `var(--foo,)` and `env(--foo,)` (#1142) --- src/lib.rs | 16 ++++++++++++++++ src/properties/custom.rs | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 86f3d0df..eefef768 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23106,6 +23106,22 @@ mod tests { minify_test(".foo { --test: .5s; }", ".foo{--test:.5s}"); minify_test(".foo { --theme-sizes-1\\/12: 2 }", ".foo{--theme-sizes-1\\/12:2}"); minify_test(".foo { --test: 0px; }", ".foo{--test:0px}"); + test( + ".foo { transform: var(--bar, ) }", + indoc! {r#" + .foo { + transform: var(--bar, ); + } + "#}, + ); + test( + ".foo { transform: env(--bar, ) }", + indoc! {r#" + .foo { + transform: env(--bar, ); + } + "#}, + ); // Test attr() function with type() syntax - minified minify_test( diff --git a/src/properties/custom.rs b/src/properties/custom.rs index 46a3858d..26da0570 100644 --- a/src/properties/custom.rs +++ b/src/properties/custom.rs @@ -1215,7 +1215,10 @@ impl<'i> Variable<'i> { dest.write_str("var(")?; self.name.to_css(dest)?; if let Some(fallback) = &self.fallback { - dest.delim(',', false)?; + dest.write_char(',')?; + if !fallback.starts_with_whitespace() { + dest.whitespace()?; + } fallback.to_css(dest, is_custom_property)?; } dest.write_char(')') @@ -1389,7 +1392,10 @@ impl<'i> EnvironmentVariable<'i> { } if let Some(fallback) = &self.fallback { - dest.delim(',', false)?; + dest.write_char(',')?; + if !fallback.starts_with_whitespace() { + dest.whitespace()?; + } fallback.to_css(dest, is_custom_property)?; } dest.write_char(')') From 3136cbb5731781aaf78af5587d6d21beccc6c42a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Thu, 19 Feb 2026 02:12:37 +0800 Subject: [PATCH 28/33] fix(color-scheme): for unknown keywords, output them as-is instead of `normal` (#1152) - Rework `ColorScheme::parse` to consume only known keywords (`normal`, `only`, `light`, `dark`) instead of swallowing unknown idents. This lets `Property::parse` fail `expect_exhausted` and fall back to `Property::Unparsed`, so values like `inherit`, `unset`, and unknown identifiers are kept and serialized as-is rather than being normalized to `normal`. - Also fix `ColorScheme::to_css` to serialize `only` without a leading space when it appears alone. --- src/lib.rs | 10 +++++-- src/properties/ui.rs | 63 +++++++++++++++++++++++++++----------------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index eefef768..cfc177c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30395,14 +30395,20 @@ mod tests { minify_test(".foo { color-scheme: dark light; }", ".foo{color-scheme:light dark}"); minify_test(".foo { color-scheme: only light; }", ".foo{color-scheme:light only}"); minify_test(".foo { color-scheme: only dark; }", ".foo{color-scheme:dark only}"); + minify_test(".foo { color-scheme: inherit; }", ".foo{color-scheme:inherit}"); + minify_test(":root { color-scheme: unset; }", ":root{color-scheme:unset}"); + minify_test(".foo { color-scheme: unknow; }", ".foo{color-scheme:unknow}"); + minify_test(".foo { color-scheme: only; }", ".foo{color-scheme:only}"); + minify_test(".foo { color-scheme: dark foo; }", ".foo{color-scheme:dark foo}"); + minify_test(".foo { color-scheme: normal dark; }", ".foo{color-scheme:normal dark}"); minify_test( ".foo { color-scheme: dark light only; }", ".foo{color-scheme:light dark only}", ); - minify_test(".foo { color-scheme: foo bar light; }", ".foo{color-scheme:light}"); + minify_test(".foo { color-scheme: foo bar light; }", ".foo{color-scheme:foo bar light}"); minify_test( ".foo { color-scheme: only foo dark bar; }", - ".foo{color-scheme:dark only}", + ".foo{color-scheme:only foo dark bar}", ); prefix_test( ".foo { color-scheme: dark; }", diff --git a/src/properties/ui.rs b/src/properties/ui.rs index 06b83ab9..e0802c50 100644 --- a/src/properties/ui.rs +++ b/src/properties/ui.rs @@ -433,33 +433,44 @@ bitflags! { impl<'i> Parse<'i> for ColorScheme { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { let mut res = ColorScheme::empty(); - let ident = input.expect_ident()?; - match_ignore_ascii_case! { &ident, - "normal" => return Ok(res), - "only" => res |= ColorScheme::Only, - "light" => res |= ColorScheme::Light, - "dark" => res |= ColorScheme::Dark, - _ => {} - }; + let mut has_any = false; - while let Ok(ident) = input.try_parse(|input| input.expect_ident_cloned()) { - match_ignore_ascii_case! { &ident, - "normal" => return Err(input.new_custom_error(ParserError::InvalidValue)), - "only" => { - // Only must be at the start or the end, not in the middle. - if res.contains(ColorScheme::Only) { - return Err(input.new_custom_error(ParserError::InvalidValue)); - } - res |= ColorScheme::Only; - return Ok(res); - }, - "light" => res |= ColorScheme::Light, - "dark" => res |= ColorScheme::Dark, - _ => {} - }; + if input.try_parse(|input| input.expect_ident_matching("normal")).is_ok() { + return Ok(res); + } + + if input.try_parse(|input| input.expect_ident_matching("only")).is_ok() { + res |= ColorScheme::Only; + has_any = true; } - Ok(res) + loop { + if input.try_parse(|input| input.expect_ident_matching("light")).is_ok() { + res |= ColorScheme::Light; + has_any = true; + continue; + } + + if input.try_parse(|input| input.expect_ident_matching("dark")).is_ok() { + res |= ColorScheme::Dark; + has_any = true; + continue; + } + + break; + } + + // Only is allowed at the start or the end. + if !res.contains(ColorScheme::Only) && input.try_parse(|input| input.expect_ident_matching("only")).is_ok() { + res |= ColorScheme::Only; + has_any = true; + } + + if has_any { + return Ok(res); + } + + Err(input.new_custom_error(ParserError::InvalidValue)) } } @@ -484,6 +495,10 @@ impl ToCss for ColorScheme { } if self.contains(ColorScheme::Only) { + // Avoid parsing `color-scheme: only` as `color-scheme: only` + if !self.intersects(ColorScheme::Light | ColorScheme::Dark) { + return dest.write_str("only"); + } dest.write_str(" only")?; } From 89d56f67baddb8a3f8aa2673b4a972cebaff6c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Thu, 19 Feb 2026 02:16:06 +0800 Subject: [PATCH 29/33] feat: improved serialization of the `rotate` property (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the serialization of the `rotate` property value according to the CSS Transforms 2 specification. Previously, `rotate: 0deg` was incorrectly serialized as `rotate: none`. The specification clearly states that `rotate: 0deg` creates a stacking context and containing block, while `rotate: none` does not. See Demo: https://codepen.io/yisi/pen/QwEJEmr?editors=0100 > All three properties accept (and default to) the value none, which produces no transform at all. In particular, this value does not trigger the creation of a stacking context or containing block for all descendants, while all other values (including “identity” transforms like translate: 0px) create a stacking context and containing block for all descendants, per usual for transforms. > https://drafts.csswg.org/css-transforms-2/#valdef-translate-none This PR fixes this issue and also provides additional compression benefits. - `minify_test(".foo { rotate: -0deg }", ".foo{rotate:0deg}");` - `minify_test(".foo { rotate: z 10deg }", ".foo{rotate:10deg}");` - `minify_test(".foo { rotate: 0 0 1 10deg }", ".foo{rotate:10deg}");` - `minify_test(".foo { rotate: 10deg 0 0 -1 }", ".foo{rotate:-10deg}");` Fixes: https://github.com/parcel-bundler/lightningcss/issues/1136 --- src/lib.rs | 28 +++++++++-- src/properties/transform.rs | 96 +++++++++++++++++++++++-------------- src/values/angle.rs | 2 + 3 files changed, 85 insertions(+), 41 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cfc177c0..61d6054e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8281,13 +8281,13 @@ mod tests { minify_test(".foo { rotate: acos(cos(45deg))", ".foo{rotate:45deg}"); minify_test(".foo { rotate: acos(-1)", ".foo{rotate:180deg}"); minify_test(".foo { rotate: acos(0)", ".foo{rotate:90deg}"); - minify_test(".foo { rotate: acos(1)", ".foo{rotate:none}"); + minify_test(".foo { rotate: acos(1)", ".foo{rotate:0deg}"); minify_test(".foo { rotate: acos(45deg)", ".foo{rotate:acos(45deg)}"); // invalid minify_test(".foo { rotate: acos(-20)", ".foo{rotate:acos(-20)}"); // evaluates to NaN minify_test(".foo { rotate: atan(tan(45deg))", ".foo{rotate:45deg}"); minify_test(".foo { rotate: atan(1)", ".foo{rotate:45deg}"); - minify_test(".foo { rotate: atan(0)", ".foo{rotate:none}"); + minify_test(".foo { rotate: atan(0)", ".foo{rotate:0deg}"); minify_test(".foo { rotate: atan(45deg)", ".foo{rotate:atan(45deg)}"); // invalid minify_test(".foo { rotate: atan2(1px, -1px)", ".foo{rotate:135deg}"); @@ -8301,6 +8301,9 @@ mod tests { minify_test(".foo { rotate: atan2(-1, 1)", ".foo{rotate:-45deg}"); // incompatible units minify_test(".foo { rotate: atan2(1px, -1vw)", ".foo{rotate:atan2(1px, -1vw)}"); + + minify_test(".foo { transform: rotate(acos(1)) }", ".foo{transform:rotate(0)}"); + minify_test(".foo { transform: rotate(atan(0)) }", ".foo{transform:rotate(0)}"); } #[test] @@ -12832,16 +12835,31 @@ mod tests { minify_test(".foo { translate: 1px 2px 0px }", ".foo{translate:1px 2px}"); minify_test(".foo { translate: 1px 0px 2px }", ".foo{translate:1px 0 2px}"); minify_test(".foo { translate: none }", ".foo{translate:none}"); + minify_test(".foo { rotate: none }", ".foo{rotate:none}"); + minify_test(".foo { rotate: 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: -0deg }", ".foo{rotate:0deg}"); minify_test(".foo { rotate: 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: z 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: 0 0 1 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: x 10deg }", ".foo{rotate:x 10deg}"); minify_test(".foo { rotate: 1 0 0 10deg }", ".foo{rotate:x 10deg}"); - minify_test(".foo { rotate: y 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: 2 0 0 10deg }", ".foo{rotate:x 10deg}"); + minify_test(".foo { rotate: 0 2 0 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: 0 0 2 10deg }", ".foo{rotate:10deg}"); + minify_test(".foo { rotate: 0 0 5.3 10deg }", ".foo{rotate:10deg}"); + minify_test(".foo { rotate: 0 0 1 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 10deg 0 0 -1 }", ".foo{rotate:-10deg}"); + minify_test(".foo { rotate: 10deg 0 0 -233 }", ".foo{rotate:-10deg}"); + minify_test(".foo { rotate: -1 0 0 0deg }", ".foo{rotate:x 0deg}"); + minify_test(".foo { rotate: 0deg 0 0 1 }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 0deg 0 0 -1 }", ".foo{rotate:0deg}"); minify_test(".foo { rotate: 0 1 0 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: x 0rad }", ".foo{rotate:x 0deg}"); + // TODO: In minify mode, convert units to the shortest form. + // minify_test(".foo { rotate: y 0turn }", ".foo{rotate:y 0deg}"); + minify_test(".foo { rotate: z 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 10deg y }", ".foo{rotate:y 10deg}"); minify_test(".foo { rotate: 1 1 1 10deg }", ".foo{rotate:1 1 1 10deg}"); - minify_test(".foo { rotate: 0 0 1 0deg }", ".foo{rotate:none}"); - minify_test(".foo { rotate: none }", ".foo{rotate:none}"); minify_test(".foo { scale: 1 }", ".foo{scale:1}"); minify_test(".foo { scale: 1 1 }", ".foo{scale:1}"); minify_test(".foo { scale: 1 1 1 }", ".foo{scale:1}"); diff --git a/src/properties/transform.rs b/src/properties/transform.rs index c8f5a7f7..60f3bb50 100644 --- a/src/properties/transform.rs +++ b/src/properties/transform.rs @@ -1518,29 +1518,39 @@ impl Translate { /// A value for the [rotate](https://drafts.csswg.org/css-transforms-2/#propdef-rotate) property. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "lowercase") +)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "into_owned", derive(static_self::IntoOwned))] -pub struct Rotate { - /// Rotation around the x axis. - pub x: f32, - /// Rotation around the y axis. - pub y: f32, - /// Rotation around the z axis. - pub z: f32, - /// The angle of rotation. - pub angle: Angle, +pub enum Rotate { + /// The `none` keyword. + None, + + /// Rotation on the x, y, and z axes. + #[cfg_attr(feature = "serde", serde(untagged))] + XYZ { + /// Rotation around the x axis. + x: f32, + /// Rotation around the y axis. + y: f32, + /// Rotation around the z axis. + z: f32, + /// The angle of rotation. + angle: Angle, + }, } impl<'i> Parse<'i> for Rotate { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { + // CSS Transforms 2 §5.1: + // "It must serialize as the keyword none if and only if none was originally specified." + // Keep `none` explicit so identity rotations (e.g. `0deg`) do not round-trip to `none`. + // https://drafts.csswg.org/css-transforms-2/#individual-transforms if input.try_parse(|i| i.expect_ident_matching("none")).is_ok() { - return Ok(Rotate { - x: 0.0, - y: 0.0, - z: 1.0, - angle: Angle::Deg(0.0), - }); + return Ok(Rotate::None); } let angle = input.try_parse(Angle::parse); @@ -1564,7 +1574,7 @@ impl<'i> Parse<'i> for Rotate { ) .unwrap_or((0.0, 0.0, 1.0)); let angle = angle.or_else(|_| Angle::parse(input))?; - Ok(Rotate { x, y, z, angle }) + Ok(Rotate::XYZ { x, y, z, angle }) } } @@ -1573,32 +1583,46 @@ impl ToCss for Rotate { where W: std::fmt::Write, { - if self.x == 0.0 && self.y == 0.0 && self.z == 1.0 && self.angle.is_zero() { - dest.write_str("none")?; - return Ok(()); - } - - if self.x == 1.0 && self.y == 0.0 && self.z == 0.0 { - dest.write_str("x ")?; - } else if self.x == 0.0 && self.y == 1.0 && self.z == 0.0 { - dest.write_str("y ")?; - } else if !(self.x == 0.0 && self.y == 0.0 && self.z == 1.0) { - self.x.to_css(dest)?; - dest.write_char(' ')?; - self.y.to_css(dest)?; - dest.write_char(' ')?; - self.z.to_css(dest)?; - dest.write_char(' ')?; + match self { + Rotate::None => dest.write_str("none"), + Rotate::XYZ { x, y, z, angle } => { + // CSS Transforms 2 §5.1: + // "If the axis is parallel with the x or y axes, it must serialize as the appropriate keyword." + // "If a rotation about the z axis ... must serialize as just an ." + // Normalize parallel vectors (including non-unit vectors); flip the angle for negative axis directions. + // https://drafts.csswg.org/css-transforms-2/#individual-transforms + if *y == 0.0 && *z == 0.0 && *x != 0.0 { + let angle = if *x < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + dest.write_str("x ")?; + angle.to_css(dest) + } else if *x == 0.0 && *z == 0.0 && *y != 0.0 { + let angle = if *y < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + dest.write_str("y ")?; + angle.to_css(dest) + } else if *x == 0.0 && *y == 0.0 && *z != 0.0 { + let angle = if *z < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + angle.to_css(dest) + } else { + x.to_css(dest)?; + dest.write_char(' ')?; + y.to_css(dest)?; + dest.write_char(' ')?; + z.to_css(dest)?; + dest.write_char(' ')?; + angle.to_css(dest) + } + } } - - self.angle.to_css(dest) } } impl Rotate { /// Converts the rotation to a transform function. pub fn to_transform(&self) -> Transform { - Transform::Rotate3d(self.x, self.y, self.z, self.angle.clone()) + match self { + Rotate::None => Transform::Rotate3d(0.0, 0.0, 1.0, Angle::Deg(0.0)), + Rotate::XYZ { x, y, z, angle } => Transform::Rotate3d(*x, *y, *z, angle.clone()), + } } } diff --git a/src/values/angle.rs b/src/values/angle.rs index dff23a28..b7fb6232 100644 --- a/src/values/angle.rs +++ b/src/values/angle.rs @@ -121,6 +121,8 @@ impl ToCss for Angle { } Angle::Turn(val) => (*val, "turn"), }; + // Canonicalize negative zero so serialization is stable (`0deg` instead of `-0deg`). + let value = if value == 0.0 { 0.0 } else { value }; serialize_dimension(value, unit, dest) } From 3fa29c7952f47abbb2489b555d6b2c4c2c0924da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Thu, 19 Feb 2026 02:22:39 +0800 Subject: [PATCH 30/33] fix: keep a single space between functions when formatting `transform` values (#1145) - insert exactly one space between adjacent transform functions in non-minified output - keep minified output unchanged --- src/lib.rs | 29 +++++++++++++++++++++++++++++ src/properties/transform.rs | 21 ++++++--------------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 61d6054e..8d699656 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12633,6 +12633,35 @@ mod tests { #[test] fn test_transform() { + test( + ".foo { transform: perspective(500px)translate3d(10px, 0, 20px)rotateY(30deg) }", + indoc! {r#" + .foo { + transform: perspective(500px) translate3d(10px, 0, 20px) rotateY(30deg); + } + "#}, + ); + test( + ".foo { transform: translate3d(12px,50%,3em)scale(2,.5) }", + indoc! {r#" + .foo { + transform: translate3d(12px, 50%, 3em) scale(2, .5); + } + "#}, + ); + test( + ".foo { transform:matrix(1,2,-1,1,80,80) }", + indoc! {r#" + .foo { + transform: matrix(1, 2, -1, 1, 80, 80); + } + "#}, + ); + + minify_test( + ".foo { transform: scale( 0.5 )translateX(10px ) }", + ".foo{transform:scale(.5)translate(10px)}", + ); minify_test( ".foo { transform: translate(2px, 3px)", ".foo{transform:translate(2px,3px)}", diff --git a/src/properties/transform.rs b/src/properties/transform.rs index 60f3bb50..cc00e578 100644 --- a/src/properties/transform.rs +++ b/src/properties/transform.rs @@ -7,7 +7,6 @@ use crate::error::{ParserError, PrinterError}; use crate::macros::enum_property; use crate::prefixes::Feature; use crate::printer::Printer; -use crate::stylesheet::PrinterOptions; use crate::traits::{Parse, PropertyHandler, ToCss, Zero}; use crate::values::{ angle::Angle, @@ -59,20 +58,6 @@ impl ToCss for TransformList { // TODO: Re-enable with a better solution // See: https://github.com/parcel-bundler/lightningcss/issues/288 - if dest.minify { - let mut base = String::new(); - self.to_css_base(&mut Printer::new( - &mut base, - PrinterOptions { - minify: true, - ..PrinterOptions::default() - }, - ))?; - - dest.write_str(&base)?; - - return Ok(()); - } // if dest.minify { // // Combine transforms into a single matrix. // if let Some(matrix) = self.to_matrix() { @@ -141,7 +126,13 @@ impl TransformList { where W: std::fmt::Write, { + let mut first = true; for item in &self.0 { + if first { + first = false; + } else { + dest.whitespace()?; + } item.to_css(dest)?; } Ok(()) From da5b49104300b283e00dfb01557b88af122a78b0 Mon Sep 17 00:00:00 2001 From: ElirazKed Date: Wed, 18 Feb 2026 20:28:07 +0200 Subject: [PATCH 31/33] Add mix-blend-mode property support (#1148) Add typed parsing for the mix-blend-mode CSS property using a BlendMode enum with all 18 standard blend mode keywords from the CSS Compositing spec. Previously this property fell through to the generic custom property handler. Co-authored-by: Claude Opus 4.6 --- src/lib.rs | 76 +++++++++++++++++++++++++++++++++++++++ src/properties/effects.rs | 43 ++++++++++++++++++++++ src/properties/mod.rs | 3 ++ 3 files changed, 122 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 8d699656..2a41655e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27921,6 +27921,82 @@ mod tests { ); } + #[test] + fn test_mix_blend_mode() { + minify_test( + ".foo { mix-blend-mode: normal }", + ".foo{mix-blend-mode:normal}", + ); + minify_test( + ".foo { mix-blend-mode: multiply }", + ".foo{mix-blend-mode:multiply}", + ); + minify_test( + ".foo { mix-blend-mode: screen }", + ".foo{mix-blend-mode:screen}", + ); + minify_test( + ".foo { mix-blend-mode: overlay }", + ".foo{mix-blend-mode:overlay}", + ); + minify_test( + ".foo { mix-blend-mode: darken }", + ".foo{mix-blend-mode:darken}", + ); + minify_test( + ".foo { mix-blend-mode: lighten }", + ".foo{mix-blend-mode:lighten}", + ); + minify_test( + ".foo { mix-blend-mode: color-dodge }", + ".foo{mix-blend-mode:color-dodge}", + ); + minify_test( + ".foo { mix-blend-mode: color-burn }", + ".foo{mix-blend-mode:color-burn}", + ); + minify_test( + ".foo { mix-blend-mode: hard-light }", + ".foo{mix-blend-mode:hard-light}", + ); + minify_test( + ".foo { mix-blend-mode: soft-light }", + ".foo{mix-blend-mode:soft-light}", + ); + minify_test( + ".foo { mix-blend-mode: difference }", + ".foo{mix-blend-mode:difference}", + ); + minify_test( + ".foo { mix-blend-mode: exclusion }", + ".foo{mix-blend-mode:exclusion}", + ); + minify_test( + ".foo { mix-blend-mode: hue }", + ".foo{mix-blend-mode:hue}", + ); + minify_test( + ".foo { mix-blend-mode: saturation }", + ".foo{mix-blend-mode:saturation}", + ); + minify_test( + ".foo { mix-blend-mode: color }", + ".foo{mix-blend-mode:color}", + ); + minify_test( + ".foo { mix-blend-mode: luminosity }", + ".foo{mix-blend-mode:luminosity}", + ); + minify_test( + ".foo { mix-blend-mode: plus-darker }", + ".foo{mix-blend-mode:plus-darker}", + ); + minify_test( + ".foo { mix-blend-mode: plus-lighter }", + ".foo{mix-blend-mode:plus-lighter}", + ); + } + #[test] fn test_viewport() { minify_test( diff --git a/src/properties/effects.rs b/src/properties/effects.rs index 1ba69f6a..95d403d4 100644 --- a/src/properties/effects.rs +++ b/src/properties/effects.rs @@ -1,5 +1,6 @@ //! CSS properties related to filters and effects. +use crate::macros::enum_property; use crate::error::{ParserError, PrinterError}; use crate::printer::Printer; use crate::targets::{Browsers, Targets}; @@ -410,3 +411,45 @@ impl IsCompatible for FilterList<'_> { true } } + +enum_property! { + /// A [``](https://www.w3.org/TR/compositing-1/#ltblendmodegt) value. + pub enum BlendMode { + /// The default blend mode; the top layer is drawn over the bottom layer. + Normal, + /// The source and destination are multiplied. + Multiply, + /// Multiplies the complements of the backdrop and source, then complements the result. + Screen, + /// Multiplies or screens, depending on the backdrop color. + Overlay, + /// Selects the darker of the backdrop and source. + Darken, + /// Selects the lighter of the backdrop and source. + Lighten, + /// Brightens the backdrop to reflect the source. + ColorDodge, + /// Darkens the backdrop to reflect the source. + ColorBurn, + /// Multiplies or screens, depending on the source color. + HardLight, + /// Darkens or lightens, depending on the source color. + SoftLight, + /// Subtracts the darker from the lighter. + Difference, + /// Similar to difference, but with lower contrast. + Exclusion, + /// The hue of the source with the saturation and luminosity of the backdrop. + Hue, + /// The saturation of the source with the hue and luminosity of the backdrop. + Saturation, + /// The hue and saturation of the source with the luminosity of the backdrop. + Color, + /// The luminosity of the source with the hue and saturation of the backdrop. + Luminosity, + /// Adds the source to the backdrop, producing a darker result. + PlusDarker, + /// Adds the source to the backdrop, producing a lighter result. + PlusLighter, + } +} diff --git a/src/properties/mod.rs b/src/properties/mod.rs index 54c47548..c39b618b 100644 --- a/src/properties/mod.rs +++ b/src/properties/mod.rs @@ -1600,6 +1600,9 @@ define_properties! { "filter": Filter(FilterList<'i>, VendorPrefix) / WebKit, "backdrop-filter": BackdropFilter(FilterList<'i>, VendorPrefix) / WebKit, + // https://www.w3.org/TR/compositing-1/ + "mix-blend-mode": MixBlendMode(BlendMode), + // https://drafts.csswg.org/css2/ "z-index": ZIndex(position::ZIndex), From 4fe3a4b8cbbe45be3284f0162b48c437c3b7de9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0?= Date: Fri, 20 Feb 2026 05:08:15 +0900 Subject: [PATCH 32/33] Enable custom resolvers to mark imports as external (#880) --- napi/src/lib.rs | 67 ++++++----- node/src/lib.rs | 5 +- node/test/bundle.test.mjs | 27 ++++- src/bundler.rs | 200 ++++++++++++++++++++++++++------ tests/testdata/has_external.css | 3 + 5 files changed, 229 insertions(+), 73 deletions(-) create mode 100644 tests/testdata/has_external.css diff --git a/napi/src/lib.rs b/napi/src/lib.rs index dff48805..f43a8d46 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -121,8 +121,8 @@ pub fn transform_style_attribute(ctx: CallContext) -> napi::Result { mod bundle { use super::*; use crossbeam_channel::{self, Receiver, Sender}; - use lightningcss::bundler::FileProvider; - use napi::{Env, JsFunction, JsString, NapiRaw}; + use lightningcss::bundler::{FileProvider, ResolveResult}; + use napi::{Env, JsBoolean, JsFunction, JsString, NapiRaw}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Mutex; @@ -169,6 +169,7 @@ mod bundle { // Allocate a single channel per thread to communicate with the JS thread. thread_local! { static CHANNEL: (Sender>, Receiver>) = crossbeam_channel::unbounded(); + static RESOLVER_CHANNEL: (Sender>, Receiver>) = crossbeam_channel::unbounded(); } impl SourceProvider for JsSourceProvider { @@ -203,9 +204,9 @@ mod bundle { } } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { if let Some(resolve) = &self.resolve { - return CHANNEL.with(|channel| { + return RESOLVER_CHANNEL.with(|channel| { let message = ResolveMessage { specifier: specifier.to_owned(), originating_file: originating_file.to_str().unwrap().to_owned(), @@ -213,22 +214,18 @@ mod bundle { }; resolve.call(message, ThreadsafeFunctionCallMode::Blocking); - let result = channel.1.recv().unwrap(); - match result { - Ok(result) => Ok(PathBuf::from_str(&result).unwrap()), - Err(e) => Err(e), - } + channel.1.recv().unwrap() }); } - Ok(originating_file.with_file_name(specifier)) + Ok(originating_file.with_file_name(specifier).into()) } } struct ResolveMessage { specifier: String, originating_file: String, - tx: Sender>, + tx: Sender>, } struct ReadMessage { @@ -241,7 +238,11 @@ mod bundle { tx: Sender>, } - fn await_promise(env: Env, result: JsUnknown, tx: Sender>) -> napi::Result<()> { + fn await_promise(env: Env, result: JsUnknown, tx: Sender>, parse: Cb) -> napi::Result<()> + where + T: 'static, + Cb: 'static + Fn(JsUnknown) -> Result, + { // If the result is a promise, wait for it to resolve, and send the result to the channel. // Otherwise, send the result immediately. if result.is_promise()? { @@ -249,9 +250,8 @@ mod bundle { let then: JsFunction = get_named_property(&result, "then")?; let tx2 = tx.clone(); let cb = env.create_function_from_closure("callback", move |ctx| { - let res = ctx.get::(0)?.into_utf8()?; - let s = res.into_owned()?; - tx.send(Ok(s)).unwrap(); + let res = parse(ctx.get::(0)?)?; + tx.send(Ok(res)).unwrap(); ctx.env.get_undefined() })?; let eb = env.create_function_from_closure("error_callback", move |ctx| { @@ -261,10 +261,8 @@ mod bundle { })?; then.call(Some(&result), &[cb, eb])?; } else { - let result: JsString = result.try_into()?; - let utf8 = result.into_utf8()?; - let s = utf8.into_owned()?; - tx.send(Ok(s)).unwrap(); + let result = parse(result)?; + tx.send(Ok(result)).unwrap(); } Ok(()) @@ -274,10 +272,12 @@ mod bundle { let specifier = ctx.env.create_string(&ctx.value.specifier)?; let originating_file = ctx.env.create_string(&ctx.value.originating_file)?; let result = ctx.callback.unwrap().call(None, &[specifier, originating_file])?; - await_promise(ctx.env, result, ctx.value.tx) + await_promise(ctx.env, result, ctx.value.tx, move |unknown| { + ctx.env.from_js_value(unknown) + }) } - fn handle_error(tx: Sender>, res: napi::Result<()>) -> napi::Result<()> { + fn handle_error(tx: Sender>, res: napi::Result<()>) -> napi::Result<()> { match res { Ok(_) => Ok(()), Err(e) => { @@ -295,7 +295,9 @@ mod bundle { fn read_on_js_thread(ctx: ThreadSafeCallContext) -> napi::Result<()> { let file = ctx.env.create_string(&ctx.value.file)?; let result = ctx.callback.unwrap().call(None, &[file])?; - await_promise(ctx.env, result, ctx.value.tx) + await_promise(ctx.env, result, ctx.value.tx, |unknown| { + JsString::try_from(unknown)?.into_utf8()?.into_owned() + }) } fn read_on_js_thread_wrapper(ctx: ThreadSafeCallContext) -> napi::Result<()> { @@ -421,10 +423,10 @@ mod bundle { #[cfg(target_arch = "wasm32")] mod bundle { use super::*; + use lightningcss::bundler::ResolveResult; use napi::{Env, JsFunction, JsString, NapiRaw, NapiValue, Ref}; use std::cell::UnsafeCell; - use std::path::{Path, PathBuf}; - use std::str::FromStr; + use std::path::Path; pub fn bundle(ctx: CallContext) -> napi::Result { let opts = ctx.get::(0)?; @@ -497,7 +499,7 @@ mod bundle { ); } - fn get_result(env: Env, mut value: JsUnknown) -> napi::Result { + fn get_result(env: Env, mut value: JsUnknown) -> napi::Result { if value.is_promise()? { let mut result = std::ptr::null_mut(); let mut error = std::ptr::null_mut(); @@ -513,7 +515,7 @@ mod bundle { value = unsafe { JsUnknown::from_raw(env.raw(), result)? }; } - value.try_into() + Ok(value) } impl SourceProvider for JsSourceProvider { @@ -523,7 +525,9 @@ mod bundle { let read: JsFunction = self.env.get_reference_value_unchecked(&self.read)?; let file = self.env.create_string(file.to_str().unwrap())?; let source: JsUnknown = read.call(None, &[file])?; - let source = get_result(self.env, source)?.into_utf8()?.into_owned()?; + let source = get_result(self.env, source)?; + let source: JsString = source.try_into()?; + let source = source.into_utf8()?.into_owned()?; // cache the result let ptr = Box::into_raw(Box::new(source)); @@ -535,16 +539,17 @@ mod bundle { Ok(unsafe { &*ptr }) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { if let Some(resolve) = &self.resolve { let resolve: JsFunction = self.env.get_reference_value_unchecked(resolve)?; let specifier = self.env.create_string(specifier)?; let originating_file = self.env.create_string(originating_file.to_str().unwrap())?; let result: JsUnknown = resolve.call(None, &[specifier, originating_file])?; - let result = get_result(self.env, result)?.into_utf8()?; - Ok(PathBuf::from_str(result.as_str()?).unwrap()) + let result = get_result(self.env, result)?; + let result = self.env.from_js_value(result)?; + Ok(result) } else { - Ok(originating_file.with_file_name(specifier)) + Ok(ResolveResult::File(originating_file.with_file_name(specifier))) } } } diff --git a/node/src/lib.rs b/node/src/lib.rs index e429b0f2..82294412 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -3,7 +3,7 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; use napi::{CallContext, JsObject, JsUnknown}; -use napi_derive::{js_function, module_exports}; +use napi_derive::js_function; #[js_function(1)] fn transform(ctx: CallContext) -> napi::Result { @@ -26,7 +26,7 @@ pub fn bundle_async(ctx: CallContext) -> napi::Result { lightningcss_napi::bundle_async(ctx) } -#[cfg_attr(not(target_arch = "wasm32"), module_exports)] +#[cfg_attr(not(target_arch = "wasm32"), napi_derive::module_exports)] fn init(mut exports: JsObject) -> napi::Result<()> { exports.create_named_method("transform", transform)?; exports.create_named_method("transformStyleAttribute", transform_style_attribute)?; @@ -45,7 +45,6 @@ pub fn register_module() { unsafe fn register(raw_env: napi::sys::napi_env, raw_exports: napi::sys::napi_value) -> napi::Result<()> { use napi::{Env, JsObject, NapiValue}; - let env = Env::from_raw(raw_env); let exports = JsObject::from_raw_unchecked(raw_env, raw_exports); init(exports) } diff --git a/node/test/bundle.test.mjs b/node/test/bundle.test.mjs index 50d113b5..4279e51c 100644 --- a/node/test/bundle.test.mjs +++ b/node/test/bundle.test.mjs @@ -365,7 +365,7 @@ test('resolve return non-string', async () => { } if (!error) throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); - assert.equal(error.message, 'expect String, got: Number'); + assert.equal(error.message, 'data did not match any variant of untagged enum ResolveResult'); assert.equal(error.fileName, 'tests/testdata/foo.css'); assert.equal(error.loc, { line: 1, @@ -414,4 +414,29 @@ test('should support throwing in visitors', async () => { assert.equal(error.message, 'Some error'); }); +test('external import', async () => { + const { code: buffer } = await bundleAsync(/** @type {import('../index').BundleAsyncOptions} */ ({ + filename: 'tests/testdata/has_external.css', + resolver: { + resolve(specifier, originatingFile) { + if (specifier === './does_not_exist.css' || specifier.startsWith('https:')) { + return {external: specifier}; + } + return path.resolve(path.dirname(originatingFile), specifier); + } + } + })); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +@import "https://fonts.googleapis.com/css2?family=Roboto&display=swap"; +@import "./does_not_exist.css"; + +.b { + height: calc(100vh - 64px); +} + `.trim(); + if (code !== expected) throw new Error(`\`testResolver()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +}); + test.run(); diff --git a/src/bundler.rs b/src/bundler.rs index 00988487..e5009a86 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -79,7 +79,7 @@ enum AtRuleParserValue<'a, T> { struct BundleStyleSheet<'i, 'o, T> { stylesheet: Option>, - dependencies: Vec, + dependencies: Vec, css_modules_deps: Vec, parent_source_index: u32, parent_dep_index: u32, @@ -89,6 +89,33 @@ struct BundleStyleSheet<'i, 'o, T> { loc: Location, } +#[derive(Debug, Clone)] +enum Dependency { + File(u32), + External(String), +} + +/// The result of [SourceProvider::resolve]. +#[derive(Debug)] +#[cfg_attr( + any(feature = "serde", feature = "nodejs"), + derive(serde::Deserialize), + serde(rename_all = "lowercase") +)] +pub enum ResolveResult { + /// An external URL. + External(String), + /// A file path. + #[serde(untagged)] + File(PathBuf), +} + +impl From for ResolveResult { + fn from(path: PathBuf) -> Self { + ResolveResult::File(path) + } +} + /// A trait to provide the contents of files to a Bundler. /// /// See [FileProvider](FileProvider) for an implementation that uses the @@ -102,7 +129,7 @@ pub trait SourceProvider: Send + Sync { /// Resolves the given import specifier to a file path given the file /// which the import originated from. - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; } /// Provides an implementation of [SourceProvider](SourceProvider) @@ -136,9 +163,9 @@ impl SourceProvider for FileProvider { Ok(unsafe { &*ptr }) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { // Assume the specifier is a relative file path and join it with current path. - Ok(originating_file.with_file_name(specifier)) + Ok(originating_file.with_file_name(specifier).into()) } } @@ -162,6 +189,11 @@ pub enum BundleErrorKind<'i, T: std::error::Error> { UnsupportedLayerCombination, /// Unsupported media query boolean logic was encountered. UnsupportedMediaBooleanLogic, + /// An external module was referenced with a CSS module "from" clause. + ReferencedExternalModuleWithCssModuleFrom, + /// An external `@import` was found after a bundled `@import`. + /// This may result in unintended selector order. + ExternalImportAfterBundledImport, /// A custom resolver error. ResolverError(#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(skip))] T), } @@ -183,6 +215,13 @@ impl<'i, T: std::error::Error> std::fmt::Display for BundleErrorKind<'i, T> { UnsupportedImportCondition => write!(f, "Unsupported import condition"), UnsupportedLayerCombination => write!(f, "Unsupported layer combination in @import"), UnsupportedMediaBooleanLogic => write!(f, "Unsupported boolean logic in @import media query"), + ReferencedExternalModuleWithCssModuleFrom => { + write!(f, "Referenced external module with CSS module \"from\" clause") + } + ExternalImportAfterBundledImport => write!( + f, + "An external `@import` was found after a bundled `@import`. This may result in unintended selector order." + ), ResolverError(err) => std::fmt::Display::fmt(&err, f), } } @@ -265,7 +304,7 @@ where // Phase 3: concatenate. let mut rules: Vec> = Vec::new(); - self.inline(&mut rules); + self.inline(&mut rules)?; let sources = self .stylesheets @@ -428,7 +467,7 @@ where } // Collect and load dependencies for this stylesheet in parallel. - let dependencies: Result, _> = stylesheet + let dependencies: Result, _> = stylesheet .rules .0 .par_iter_mut() @@ -484,16 +523,19 @@ where }; let result = match self.fs.resolve(&specifier, file) { - Ok(path) => self.load_file( - &path, - ImportRule { - layer, - media, - supports: combine_supports(rule.supports.clone(), &import.supports), - url: "".into(), - loc: import.loc, - }, - ), + Ok(ResolveResult::File(path)) => self + .load_file( + &path, + ImportRule { + layer, + media, + supports: combine_supports(rule.supports.clone(), &import.supports), + url: "".into(), + loc: import.loc, + }, + ) + .map(Dependency::File), + Ok(ResolveResult::External(url)) => Ok(Dependency::External(url)), Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( @@ -580,7 +622,7 @@ where ) -> Option>>> { if let Some(Specifier::File(f)) = specifier { let result = match self.fs.resolve(&f, file) { - Ok(path) => { + Ok(ResolveResult::File(path)) => { let res = self.load_file( &path, ImportRule { @@ -602,6 +644,13 @@ where res } + Ok(ResolveResult::External(_)) => Err(Error { + kind: BundleErrorKind::ReferencedExternalModuleWithCssModuleFrom, + loc: Some(ErrorLocation::new( + style_loc, + self.find_filename(style_loc.source_index), + )), + }), Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( @@ -646,7 +695,9 @@ where } for i in 0..stylesheets[source_index as usize].dependencies.len() { - let dep_source_index = stylesheets[source_index as usize].dependencies[i]; + let Dependency::File(dep_source_index) = stylesheets[source_index as usize].dependencies[i] else { + continue; + }; let resolved = &mut stylesheets[dep_source_index as usize]; // In browsers, every instance of an @import is evaluated, so we preserve the last. @@ -659,14 +710,16 @@ where } } - fn inline(&mut self, dest: &mut Vec>) { - process(self.stylesheets.get_mut().unwrap(), 0, dest); - - fn process<'a, T>( + fn inline( + &mut self, + dest: &mut Vec>, + ) -> Result<(), Error>> { + fn process<'a, T, E: std::error::Error>( stylesheets: &mut Vec>, source_index: u32, dest: &mut Vec>, - ) { + filename: &String, + ) -> Result<(), Error>> { let stylesheet = &mut stylesheets[source_index as usize]; let mut rules = std::mem::take(&mut stylesheet.stylesheet.as_mut().unwrap().rules.0); @@ -678,26 +731,47 @@ where // Include the dependency if this is the first instance as computed earlier. if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index as u32 { - process(stylesheets, dep_source_index, dest); + process(stylesheets, dep_source_index, dest, filename)?; } dep_index += 1; } let mut import_index = 0; + let mut has_bundled_import = false; for rule in &mut rules { match rule { - CssRule::Import(_) => { - let dep_source_index = stylesheets[source_index as usize].dependencies[import_index]; - let resolved = &stylesheets[dep_source_index as usize]; - - // Include the dependency if this is the last instance as computed earlier. - if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index { - process(stylesheets, dep_source_index, dest); + CssRule::Import(import_rule) => { + let dep_source = &stylesheets[source_index as usize].dependencies[import_index]; + match dep_source { + Dependency::File(dep_source_index) => { + let resolved = &stylesheets[*dep_source_index as usize]; + + // Include the dependency if this is the last instance as computed earlier. + if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index { + has_bundled_import = true; + process(stylesheets, *dep_source_index, dest, filename)?; + } + + *rule = CssRule::Ignored; + dep_index += 1; + } + Dependency::External(url) => { + if has_bundled_import { + return Err(Error { + kind: BundleErrorKind::ExternalImportAfterBundledImport, + loc: Some(ErrorLocation { + filename: filename.clone(), + line: import_rule.loc.line, + column: import_rule.loc.column, + }), + }); + } + import_rule.url = url.to_owned().into(); + let imp = std::mem::replace(rule, CssRule::Ignored); + dest.push(imp); + } } - - *rule = CssRule::Ignored; - dep_index += 1; import_index += 1; } CssRule::LayerStatement(_) => { @@ -739,7 +813,10 @@ where } dest.extend(rules); + Ok(()) } + + process(self.stylesheets.get_mut().unwrap(), 0, dest, &self.options.filename) } } @@ -823,8 +900,12 @@ mod tests { Ok(self.map.get(file).unwrap()) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { - Ok(originating_file.with_file_name(specifier)) + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + if specifier.starts_with("https:") { + Ok(ResolveResult::External(specifier.to_owned())) + } else { + Ok(originating_file.with_file_name(specifier).into()) + } } } @@ -843,9 +924,9 @@ mod tests { /// Resolve by stripping a `foo:` prefix off any import. Specifiers without /// this prefix fail with an error. - fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { if specifier.starts_with("foo:") { - Ok(Path::new(&specifier["foo:".len()..]).to_path_buf()) + Ok(Path::new(&specifier["foo:".len()..]).to_path_buf().into()) } else { let err = std::io::Error::new( std::io::ErrorKind::NotFound, @@ -1548,6 +1629,49 @@ mod tests { "#} ); + let res = bundle( + TestProvider { + map: fs! { + "/a.css": r#" + @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + @import './b.css'; + "#, + "/b.css": r#" + .b { color: green } + "# + }, + }, + "/a.css", + ); + assert_eq!( + res, + indoc! { r#" + @import "https://fonts.googleapis.com/css2?family=Roboto&display=swap"; + + .b { + color: green; + } + "#} + ); + + error_test( + TestProvider { + map: fs! { + "/a.css": r#" + @import './b.css'; + @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + "#, + "/b.css": r#" + .b { color: green } + "# + }, + }, + "/a.css", + Some(Box::new(|err| { + assert!(matches!(err, BundleErrorKind::ExternalImportAfterBundledImport)); + })), + ); + error_test( TestProvider { map: fs! { diff --git a/tests/testdata/has_external.css b/tests/testdata/has_external.css new file mode 100644 index 00000000..191ac502 --- /dev/null +++ b/tests/testdata/has_external.css @@ -0,0 +1,3 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); +@import './does_not_exist.css'; +@import './b.css'; From 96c5c6dedffffeff00dea93bfe3e5559af406757 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 4 Mar 2026 17:32:32 -0800 Subject: [PATCH 33/33] Allow visitors to add dependencies (#1170) --- node/composeVisitors.js | 14 +++- node/index.d.ts | 25 ++++++- node/index.js | 47 ++++++++++-- node/test/composeVisitors.test.mjs | 57 ++++++++++++++ node/test/visitor.test.mjs | 115 +++++++++++++++++++++++++++++ wasm/index.mjs | 36 ++++++++- wasm/wasm-node.mjs | 36 ++++++++- website/pages/transforms.md | 55 ++++++++++++++ 8 files changed, 362 insertions(+), 23 deletions(-) diff --git a/node/composeVisitors.js b/node/composeVisitors.js index 9d5796e3..f2993490 100644 --- a/node/composeVisitors.js +++ b/node/composeVisitors.js @@ -1,15 +1,23 @@ // @ts-check /** @typedef {import('./index').Visitor} Visitor */ +/** @typedef {import('./index').VisitorFunction} VisitorFunction */ /** * Composes multiple visitor objects into a single one. - * @param {Visitor[]} visitors - * @return {Visitor} + * @param {(Visitor | VisitorFunction)[]} visitors + * @return {Visitor | VisitorFunction} */ function composeVisitors(visitors) { if (visitors.length === 1) { return visitors[0]; } + + if (visitors.some(v => typeof v === 'function')) { + return (opts) => { + let v = visitors.map(v => typeof v === 'function' ? v(opts) : v); + return composeVisitors(v); + }; + } /** @type Visitor */ let res = {}; @@ -366,7 +374,7 @@ function createArrayVisitor(visitors, apply) { // For each value, call all visitors. If a visitor returns a new value, // we start over, but skip the visitor that generated the value or saw // it before (to avoid cycles). This way, visitors can be composed in any order. - for (let v = 0; v < visitors.length;) { + for (let v = 0; v < visitors.length && i < arr.length;) { if (seen.get(v)) { v++; continue; diff --git a/node/index.d.ts b/node/index.d.ts index 76d40572..6d727d75 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -63,7 +63,7 @@ export interface TransformOptions { * For optimal performance, visitors should be as specific as possible about what types of values * they care about so that JavaScript has to be called as little as possible. */ - visitor?: Visitor, + visitor?: Visitor | VisitorFunction, /** * Defines how to parse custom CSS at-rules. Each at-rule can have a prelude, defined using a CSS * [syntax string](https://drafts.css-houdini.org/css-properties-values-api/#syntax-strings), and @@ -213,6 +213,13 @@ export interface Visitor { EnvironmentVariableExit?: EnvironmentVariableVisitor | EnvironmentVariableVisitors; } +export type VisitorDependency = FileDependency | GlobDependency; +export interface VisitorOptions { + addDependency: (dep: VisitorDependency) => void +} + +export type VisitorFunction = (options: VisitorOptions) => Visitor; + export interface CustomAtRules { [name: string]: CustomAtRuleDefinition } @@ -358,7 +365,7 @@ export interface DependencyCSSModuleReference { specifier: string } -export type Dependency = ImportDependency | UrlDependency; +export type Dependency = ImportDependency | UrlDependency | FileDependency | GlobDependency; export interface ImportDependency { type: 'import', @@ -384,6 +391,16 @@ export interface UrlDependency { placeholder: string } +export interface FileDependency { + type: 'file', + filePath: string +} + +export interface GlobDependency { + type: 'glob', + glob: string +} + export interface SourceLocation { /** The file path in which the dependency exists. */ filePath: string, @@ -438,7 +455,7 @@ export interface TransformAttributeOptions { * For optimal performance, visitors should be as specific as possible about what types of values * they care about so that JavaScript has to be called as little as possible. */ - visitor?: Visitor + visitor?: Visitor | VisitorFunction } export interface TransformAttributeResult { @@ -474,4 +491,4 @@ export declare function bundleAsync(options: BundleAsyn /** * Composes multiple visitor objects into a single one. */ -export declare function composeVisitors(visitors: Visitor[]): Visitor; +export declare function composeVisitors(visitors: (Visitor | VisitorFunction)[]): Visitor | VisitorFunction; diff --git a/node/index.js b/node/index.js index 011d04b4..6fe25aef 100644 --- a/node/index.js +++ b/node/index.js @@ -13,16 +13,47 @@ if (process.platform === 'linux') { parts.push('msvc'); } -if (process.env.CSS_TRANSFORMER_WASM) { - module.exports = require(`../pkg`); -} else { - try { - module.exports = require(`lightningcss-${parts.join('-')}`); - } catch (err) { - module.exports = require(`../lightningcss.${parts.join('-')}.node`); - } +let native; +try { + native = require(`lightningcss-${parts.join('-')}`); +} catch (err) { + native = require(`../lightningcss.${parts.join('-')}.node`); } +module.exports.transform = wrap(native.transform); +module.exports.transformStyleAttribute = wrap(native.transformStyleAttribute); +module.exports.bundle = wrap(native.bundle); +module.exports.bundleAsync = wrap(native.bundleAsync); module.exports.browserslistToTargets = require('./browserslistToTargets'); module.exports.composeVisitors = require('./composeVisitors'); module.exports.Features = require('./flags').Features; + +function wrap(call) { + return (options) => { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } + }; +} diff --git a/node/test/composeVisitors.test.mjs b/node/test/composeVisitors.test.mjs index 7718ec06..4379cf48 100644 --- a/node/test/composeVisitors.test.mjs +++ b/node/test/composeVisitors.test.mjs @@ -800,4 +800,61 @@ test('StyleSheet', () => { assert.equal(styleSheetExitCalledCount, 2); }); +test('visitor function', () => { + let res = transform({ + filename: 'test.css', + minify: true, + code: Buffer.from(` + @dep "foo.js"; + @dep2 "bar.js"; + + .foo { + width: 32px; + } + `), + visitor: composeVisitors([ + ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }), + ({addDependency}) => ({ + Rule: { + unknown: { + dep2(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }) + ]) + }); + + assert.equal(res.code.toString(), '.foo{width:32px}'); + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'foo.js' + }, + { + type: 'file', + filePath: 'bar.js' + } + ]); +}); + test.run(); diff --git a/node/test/visitor.test.mjs b/node/test/visitor.test.mjs index c763b840..149825b7 100644 --- a/node/test/visitor.test.mjs +++ b/node/test/visitor.test.mjs @@ -1170,4 +1170,119 @@ test('visit stylesheet', () => { assert.equal(res.code.toString(), '.bar{width:80px}.foo{width:32px}'); }); +test('visitor function', () => { + let res = transform({ + filename: 'test.css', + minify: true, + code: Buffer.from(` + @dep "foo.js"; + + .foo { + width: 32px; + } + `), + visitor: ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }) + }); + + assert.equal(res.code.toString(), '.foo{width:32px}'); + assert.equal(res.dependencies, [{ + type: 'file', + filePath: 'foo.js' + }]); +}); + +test('visitor function works with style attributes', () => { + let res = transformStyleAttribute({ + filename: 'test.css', + minify: true, + code: Buffer.from('height: 12px'), + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [{ + type: 'file', + filePath: 'test.json' + }]); +}); + +test('visitor function works with bundler', () => { + let res = bundle({ + filename: 'tests/testdata/a.css', + minify: true, + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + } + ]); +}); + +test('works with async bundler', async () => { + let res = await bundleAsync({ + filename: 'tests/testdata/a.css', + minify: true, + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + } + ]); +}); + test.run(); diff --git a/wasm/index.mjs b/wasm/index.mjs index e7d4dc69..b74898fb 100644 --- a/wasm/index.mjs +++ b/wasm/index.mjs @@ -38,19 +38,47 @@ export default async function init(input) { } export function transform(options) { - return wasm.transform(options); + return wrap(wasm.transform, options); } export function transformStyleAttribute(options) { - return wasm.transformStyleAttribute(options); + return wrap(wasm.transformStyleAttribute, options); } export function bundle(options) { - return wasm.bundle(options); + return wrap(wasm.bundle, options); } export function bundleAsync(options) { - return bundleAsyncInternal(options); + return wrap(bundleAsyncInternal, options); +} + +function wrap(call, options) { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } } export { browserslistToTargets } from './browserslistToTargets.js'; diff --git a/wasm/wasm-node.mjs b/wasm/wasm-node.mjs index 52014444..93c05afd 100644 --- a/wasm/wasm-node.mjs +++ b/wasm/wasm-node.mjs @@ -25,15 +25,15 @@ export default async function init() { } export function transform(options) { - return wasm.transform(options); + return wrap(wasm.transform, options); } export function transformStyleAttribute(options) { - return wasm.transformStyleAttribute(options); + return wrap(wasm.transformStyleAttribute, options); } export function bundle(options) { - return wasm.bundle({ + return wrap(wasm.bundle, { ...options, resolver: { read: (filePath) => fs.readFileSync(filePath, 'utf8') @@ -49,7 +49,35 @@ export async function bundleAsync(options) { }; } - return bundleAsyncInternal(options); + return wrap(bundleAsyncInternal, options); +} + +function wrap(call, options) { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } } export { browserslistToTargets } from './browserslistToTargets.js' diff --git a/website/pages/transforms.md b/website/pages/transforms.md index 7441cb5d..0a36f13a 100644 --- a/website/pages/transforms.md +++ b/website/pages/transforms.md @@ -353,6 +353,61 @@ let res = transform({ assert.equal(res.code.toString(), '.foo{color:red}.foo.bar{color:#ff0}'); ``` +## Dependencies + +Visitors can emit dependencies so the caller (e.g. bundler) knows to re-run the transformation or invalidate a cache when those files change. These are returned as part of the result's `dependencies` property (along with other dependencies when the `analyzeDependencies` option is enabled). + +By passing a function to the `visitor` option instead of an object, you get access to the `addDependency` function. This accepts a dependency object with `type: 'file'` or `type: 'glob'`. File dependencies invalidate the transformation whenever the `filePath` changes (created, updated, or deleted). Glob dependencies invalidate whenever any file matched by the glob changes. `composeVisitors` also supports function visitors. + +By default, Lightning CSS does not do anything with these dependencies except return them to the caller. It's the caller's responsibility to implement file watching and cache invalidation accordingly. + +```js +let res = transform({ + filename: 'test.css', + code: Buffer.from(` + @dep "foo.js"; + @glob "**/*.json"; + + .foo { + width: 32px; + } + `), + visitor: ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + }, + glob(rule) { + let glob = rule.prelude[0].value.value; + addDependency({ + type: 'glob', + glob + }); + return []; + } + } + } + }) +}); + +assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'foo.js' + }, + { + type: 'glob', + filePath: '**/*.json' + } +]); +``` + ## Examples For examples of visitors that perform a variety of real world tasks, see the Lightning CSS [visitor tests](https://github.com/parcel-bundler/lightningcss/blob/master/node/test/visitor.test.mjs).