diff --git a/.changepacks/changepack_log_nested_css_results.json b/.changepacks/changepack_log_nested_css_results.json new file mode 100644 index 000000000..efb4716d9 --- /dev/null +++ b/.changepacks/changepack_log_nested_css_results.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Statically known css() results used as nested rule values expand into their source rules under each top-level comma-separated selector, preserving responsive values, nested selectors, layers and style order without runtime styling. Unknown rule sources retain a located build error naming the value and its static-value requirement.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index fab4dca4b..93fa5577d 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -110,8 +110,25 @@ pub fn split_selector_list(selector: &str) -> Vec<&str> { let mut parts = vec![]; let mut depth = 0usize; let mut start = 0; + let mut quote = None; + let mut escaped = false; for (index, c) in selector.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if c == delimiter { + quote = None; + } + continue; + } match c { + '\'' | '"' => quote = Some(c), '(' | '[' => depth += 1, ')' | ']' => depth = depth.saturating_sub(1), ',' if depth == 0 => { @@ -789,6 +806,10 @@ mod tests { #[case("&:hover", vec!["&:hover"])] #[case(" a > b , i ", vec!["a > b", "i"])] #[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])] + #[case(r#"&[title="a,]b"], &:focus"#, vec![r#"&[title="a,]b"]"#, "&:focus"])] + #[case(r"&.a\,b, &:focus", vec![r"&.a\,b", "&:focus"])] + #[case(r"&.a\(, &:focus", vec![r"&.a\(", "&:focus"])] + #[case(r#"&[title="a\",]b"], &:focus"#, vec![r#"&[title="a\",]b"]"#, "&:focus"])] fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) { assert_eq!(split_selector_list(selector), expected); } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 1927dd82d..94fcb9dab 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -392,7 +392,7 @@ pub fn extract_style_from_expression<'a>( ast_builder, part, offset, - expression, + &mut expression.clone_in(ast_builder.allocator()), level, selector, literal_handling, @@ -437,7 +437,7 @@ pub fn extract_style_from_expression<'a>( ast_builder, part, offset, - &mut o.value, + &mut o.value.clone_in(ast_builder.allocator()), level, selector, literal_handling, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a1b30ced1..9a0e4893a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11416,6 +11416,227 @@ export const e = spin.toString()", assert!(extract("a.css.ts", code, option(false)).is_ok()); } + #[rstest] + #[case::user_example( + "{ color: 'hotpink' }", + "'&:hover,&:focus': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }" + )] + #[case::inner_comma( + "{ color: 'hotpink' }", + "'&:is(a,b), &:not(.x,.y)': hotpink", + "'&:is(a,b)': { ...hotpinkRules }, '&:not(.x,.y)': { ...hotpinkRules }" + )] + #[case::three_selectors( + "{ color: 'hotpink' }", + "' &:hover, &:focus, &:active ': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:active': { ...hotpinkRules }" + )] + #[case::responsive( + "{ color: ['hotpink', null, 'blue'], p: [1, 2] }", + "'&:hover,&:focus': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }" + )] + #[case::nested_layer_order( + "{ '@layer': { base: { color: 'hotpink', _active: { color: ['red', 'blue'] } } } }", + "styleOrder: 3, '&:hover,&:focus': hotpink, '&:hover': { color: 'green' }", + "styleOrder: 3, '&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:hover': { color: 'green' }" + )] + #[case::quoted_commas( + "{ color: 'hotpink' }", + r#"'&[data-x=a,b], &[title="a,]b"]': hotpink"#, + r#"'&[data-x=a,b]': { ...hotpinkRules }, '&[title="a,]b"]': { ...hotpinkRules }"# + )] + #[serial] + fn test_css_nested_static_result( + #[case] rules: &str, + #[case] nested: &str, + #[case] expanded: &str, + #[values("css(hotpinkRules)", "css({}, hotpinkRules)")] source: &str, + ) { + let compile = |body| { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + &format!("import {{ css }} from '@devup-ui/react'; const hotpinkRules = {rules}; const hotpink = {source}; export const hover = css({{ {body} }});"), + ExtractOption::default(), + ) + .unwrap() + }; + let expected = compile(expanded); + let actual = compile(nested); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + } + + #[rstest] + #[case::unknown("")] + #[case::external_class("const hotpink = css('external', { color: 'red' });")] + #[serial] + fn test_css_nested_static_result_unknown_error(#[case] binding: &str) { + reset_class_map(); + reset_file_map(); + let error = extract( + "test.tsx", + &format!("import {{ css }} from '@devup-ui/react'\n{binding}\nexport const hover = css({{ '&:hover,&:focus': hotpink }})"), + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap_err() + .to_string(); + assert!( + error.starts_with("test.tsx:3:47: `css()` cannot use `hotpink` at build time:") + && error.contains("its values must be literals, theme tokens or constants"), + "{error}" + ); + } + + #[rstest] + #[case::composed( + "const hotpink = css(base, { p: 2 });", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::chained( + "const hotpink = css({ _active: base });", + "hotpink", + "{ _active: { color: 'hotpink' } }" + )] + #[case::keyframes( + "const hotpink = css({ animationName: spin });", + "hotpink", + "{ animationName: spin }" + )] + #[case::alias("const hotpink = base;", "hotpink", "{ color: 'hotpink' }")] + #[case::inline("", "css({ color: 'hotpink' })", "{ color: 'hotpink' }")] + #[case::inline_known_composition( + "const hotpink = css(base, { color: 'blue', _active: base });", + "css(base, { color: 'blue', _active: base })", + "{ color: 'blue', _active: { color: 'hotpink' } }" + )] + #[case::composed_later_wins( + "const hotpink = css(base, { color: 'blue', p: 2 });", + "hotpink", + "{ color: 'blue', p: 2 }" + )] + #[case::alias_chain( + "const warm = base; const hotpink = warm;", + "hotpink", + "{ color: 'hotpink' }" + )] + #[case::selector_record( + "const hotpink = css({ selectors: { '&:active': base } });", + "hotpink", + "{ selectors: { '&:active': { color: 'hotpink' } } }" + )] + #[case::declaration_string( + "const hotpink = css({ content: base });", + "hotpink", + "{ content: `${base}` }" + )] + #[case::spread_composed( + "const hotpink = css(...[base, { p: 2 }]);", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::adjacent_spreads( + "const hotpink = css(...[base], ...[{ color: 'blue', p: 2 }]);", + "hotpink", + "{ color: 'blue', p: 2 }" + )] + #[case::nested_array_spread( + "const hotpink = css(...[...[base], { p: 2 }]);", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::direct_spread_source( + "const hotpink = css(...{ _active: base });", + "hotpink", + "{ _active: { color: 'hotpink' } }" + )] + #[case::spread_declaration_string( + "const hotpink = css(...[{ content: base }, { p: 2 }]);", + "hotpink", + "{ content: `${base}`, p: 2 }" + )] + #[case::layer_record( + "", + "{ '@layer': { colors: base } }", + "{ '@layer': { colors: { color: 'hotpink' } } }" + )] + #[case::media_record( + "", + "{ '@media': { print: base } }", + "{ '@media': { print: { color: 'hotpink' } } }" + )] + #[case::supports_record( + "", + "{ '@supports': { '(display: grid)': base } }", + "{ '@supports': { '(display: grid)': { color: 'hotpink' } } }" + )] + #[case::container_record( + "", + "{ '@container': { '(min-width: 10px)': base } }", + "{ '@container': { '(min-width: 10px)': { color: 'hotpink' } } }" + )] + #[case::underscore_media_record( + "", + "{ _media: { print: base } }", + "{ _media: { print: { color: 'hotpink' } } }" + )] + #[case::record_declaration_string( + "", + "{ '@layer': { colors: { content: base } } }", + "{ '@layer': { colors: { content: `${base}` } } }" + )] + #[serial] + fn test_css_nested_static_source_references( + #[case] binding: &str, + #[case] value: &str, + #[case] expanded: &str, + ) { + let compile = |nested: &str| { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + &format!("import {{ css, keyframes }} from '@devup-ui/react'; const spin = keyframes({{ from: {{ opacity: 0 }}, to: {{ opacity: 1 }} }}); const base = css({{ color: 'hotpink' }}); {binding} export const hover = css({{ styleOrder: 3, '@layer': {{ base: {{ '&:hover,&:focus': {nested} }} }} }});"), + ExtractOption::default(), + ).unwrap() + }; + let expected = compile(expanded); + let actual = compile(value); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + } + + #[test] + #[serial] + fn test_css_nested_inline_without_binding() { + let compile = |source| { + reset_class_map(); + reset_file_map(); + extract("test.tsx", source, ExtractOption::default()).unwrap() + }; + let expected = compile( + "import { css } from '@devup-ui/react'; css({ color: 'hotpink' }); export const hover = css({ '&:hover': { color: 'hotpink' }, '&:focus': { color: 'hotpink' } });", + ); + let actual = compile( + "import { css } from '@devup-ui/react'; export const hover = css({ '&:hover,&:focus': css({ color: 'hotpink' }) });", + ); + assert_eq!(actual.styles, expected.styles); + assert_eq!( + actual.code.split("export const").last(), + expected.code.split("export const").last() + ); + } + #[test] #[serial] fn test_keyframes_no_args() { diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 440b99451..d8e4e3b05 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -2,8 +2,8 @@ //! `const fadeIn = keyframes({ ... })`: the build gives it a name, so styles //! reading the binding read that name -use oxc_allocator::{FromIn, GetAllocator}; -use oxc_ast::ast::{BindingPattern, Expression, Str, TemplateLiteral}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{Argument, BindingPattern, Expression, Str, TemplateLiteral}; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; use oxc_semantic::Scoping; @@ -14,6 +14,7 @@ use rustc_hash::FxHashMap; use crate::ExtractStyleValue; /// The name a style API call gives +#[derive(Clone)] pub enum StyleValue { /// A class `css()` gives: in CSS text it is a mixin, composed rather than /// written as text, so it is read only outside CSS text. The styles behind @@ -25,7 +26,7 @@ pub enum StyleValue { } #[derive(Default)] -pub struct StyleValues { +pub struct StyleValues<'a> { scoping: Option, values: FxHashMap, /// The styles behind `css()` classes the file imports, by binding @@ -33,15 +34,19 @@ pub struct StyleValues { /// The `const`s the file binds to a string, which a `className` reads as /// the classes they hold texts: FxHashMap, + rules: FxHashMap>, + calls: FxHashMap>, } -impl StyleValues { +impl<'a> StyleValues<'a> { pub fn new(scoping: Scoping) -> Self { Self { scoping: Some(scoping), values: FxHashMap::default(), imported: FxHashMap::default(), texts: FxHashMap::default(), + rules: FxHashMap::default(), + calls: FxHashMap::default(), } } @@ -113,7 +118,7 @@ impl StyleValues { } /// `expression`, a `className`, reading the strings the constants hold - pub fn read_in_class<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in_class(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(scoping) = self.scoping.as_ref().filter(|_| !self.texts.is_empty()) { ClassReads { ast, @@ -125,7 +130,7 @@ impl StyleValues { } /// `expression` reading what the bindings recorded hold - pub fn read_in<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(mut reads) = self.reads(ast, false) { reads.visit_expression(expression); } @@ -133,22 +138,83 @@ impl StyleValues { /// `expression` as class names, which read a class in a template literal /// too: there it is a string to join, not CSS text - pub fn read_in_class_names<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in_class_names(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(mut reads) = self.reads(ast, true) { reads.visit_expression(expression); } } /// The CSS text `template` reading what the bindings recorded hold - pub fn read_in_text<'a>(&self, ast: &AstBuilder<'a>, template: &mut TemplateLiteral<'a>) { + pub fn read_in_text(&self, ast: &AstBuilder<'a>, template: &mut TemplateLiteral<'a>) { if let Some(mut reads) = self.reads(ast, false) { reads.visit_template_literal(template); } } + /// Retain the static source, not the generated class text, for nested reuse. + pub fn record_rules( + &mut self, + ast: &AstBuilder<'a>, + offset: u32, + arguments: &[oxc_ast::ast::Argument<'a>], + ) { + let mut arguments = oxc_allocator::Vec::from_iter_in( + arguments + .iter() + .map(|argument| argument.clone_in_with_semantic_ids(ast.allocator())), + ast, + ); + for argument in &mut arguments { + let expression = match argument { + Argument::SpreadElement(spread) => &mut spread.argument, + argument => argument.to_expression_mut(), + }; + if let Some(mut reads) = self.reads(ast, false) { + reads.source = true; + reads.visit_expression(expression); + } + } + let rules = if crate::utils::reads_directly(&arguments) { + arguments + .first() + .map(|argument| match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }) + .map(|rule| rule.clone_in_with_semantic_ids(ast.allocator())) + } else { + crate::utils::style_arguments(ast, &arguments) + .filter(|parts| parts.classes.is_empty()) + .map(|parts| parts.rules) + }; + if let Some(rules) = rules { + self.calls.insert(offset, rules); + } + } + + pub fn bind_rules(&mut self, ast: &AstBuilder<'a>, symbol: SymbolId, value: &Expression<'a>) { + let rules = match crate::utils::unwrap_syntax_only(value) { + Expression::StringLiteral(text) => self.calls.get(&text.span.start), + Expression::Identifier(identifier) => { + let source = identifier.reference_id.get().and_then(|reference| { + self.scoping.as_ref()?.get_reference(reference).symbol_id() + }); + if let Some(value) = source.and_then(|source| self.values.get(&source)).cloned() { + self.values.insert(symbol, value); + } + source.and_then(|source| self.rules.get(&source)) + } + _ => None, + }; + if let Some(rules) = rules { + self.rules + .insert(symbol, rules.clone_in_with_semantic_ids(ast.allocator())); + } + } + /// `program` with each `name.getName()` on a `keyframes()` binding read as /// the name it gives, as styled-components' keyframes object returns it - pub fn read_names<'a>(&self, ast: &AstBuilder<'a>, program: &mut oxc_ast::ast::Program<'a>) { + pub fn read_names(&self, ast: &AstBuilder<'a>, program: &mut oxc_ast::ast::Program<'a>) { let Some(scoping) = self.scoping.as_ref() else { return; }; @@ -166,14 +232,21 @@ impl StyleValues { } } - fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>, in_rules: bool) -> Option> { + fn reads<'s>(&'s self, ast: &'s AstBuilder<'a>, in_rules: bool) -> Option> { let scoping = self.scoping.as_ref()?; - (!self.values.is_empty()).then_some(Reads { + (!self.values.is_empty() || !self.calls.is_empty()).then_some(Reads { ast, scoping, values: &self.values, - in_text: false, - in_rules, + context: if in_rules { + ReadContext::Rules + } else { + ReadContext::Value + }, + rules: &self.rules, + calls: &self.calls, + source: false, + nested_rules: !in_rules, }) } } @@ -209,16 +282,45 @@ impl<'a> VisitMut<'a> for Names<'_, 'a> { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ReadContext { + Value, + CssText, + Rules, + RuleRecord, +} + struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, values: &'s FxHashMap, - in_text: bool, - in_rules: bool, + context: ReadContext, + rules: &'s FxHashMap>, + calls: &'s FxHashMap>, + source: bool, + nested_rules: bool, } impl<'a> VisitMut<'a> for Reads<'_, 'a> { fn visit_expression(&mut self, it: &mut Expression<'a>) { + if self.source + && let Expression::Identifier(identifier) = it + && let Some(rules) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.rules.get(&symbol)) + { + *it = rules.clone_in_with_semantic_ids(self.ast.allocator()); + return; + } + if self.source + && let Expression::StringLiteral(text) = it + && let Some(rules) = self.calls.get(&text.span.start) + { + *it = rules.clone_in_with_semantic_ids(self.ast.allocator()); + return; + } if let Expression::Identifier(identifier) = it && let Some(value) = identifier .reference_id @@ -227,7 +329,7 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { .and_then(|symbol| self.values.get(&symbol)) { let value = match value { - StyleValue::Class(..) if self.in_text => return, + StyleValue::Class(..) if self.context == ReadContext::CssText => return, StyleValue::Class(value, _) | StyleValue::Keyframes(value) => value, }; *it = Expression::new_string_literal( @@ -242,20 +344,69 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { } fn visit_template_literal(&mut self, it: &mut TemplateLiteral<'a>) { - let outer = std::mem::replace(&mut self.in_text, !self.in_rules); + let context = match self.context { + ReadContext::Value | ReadContext::CssText => ReadContext::CssText, + ReadContext::Rules | ReadContext::RuleRecord => ReadContext::Rules, + }; + let outer = std::mem::replace(&mut self.context, context); walk_mut::walk_template_literal(self, it); - self.in_text = outer; + self.context = outer; } /// A rule object holds strings, not CSS text: a template in its keys or /// values reads a class as the text it is fn visit_object_expression(&mut self, it: &mut oxc_ast::ast::ObjectExpression<'a>) { + let context = match self.context { + ReadContext::RuleRecord => ReadContext::RuleRecord, + ReadContext::Value | ReadContext::CssText | ReadContext::Rules => ReadContext::Rules, + }; let outer = ( - std::mem::replace(&mut self.in_rules, true), - std::mem::replace(&mut self.in_text, false), + std::mem::replace(&mut self.context, context), + std::mem::replace(&mut self.source, false), ); walk_mut::walk_object_expression(self, it); - (self.in_rules, self.in_text) = outer; + (self.context, self.source) = outer; + } + + fn visit_object_property(&mut self, it: &mut oxc_ast::ast::ObjectProperty<'a>) { + let key = it.key.static_name(); + let nested = self.nested_rules + && (self.context == ReadContext::RuleRecord + || key.as_deref().is_some_and(|key| { + key.contains('&') || key.starts_with([':', '_', '@']) || key == "selectors" + })); + if !nested { + walk_mut::walk_object_property(self, it); + return; + } + if let Expression::Identifier(identifier) = &it.value + && let Some(symbol) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + && matches!(self.values.get(&symbol), Some(StyleValue::Class(..))) + && !self.rules.contains_key(&symbol) + { + return; + } + let outer = std::mem::replace(&mut self.source, true); + let context = std::mem::replace( + &mut self.context, + if key.as_deref().is_some_and(|key| { + key == "selectors" + || key == "@layer" + || crate::extractor::extract_style_from_expression::at_rule_record_kind(key) + .is_some() + }) { + ReadContext::RuleRecord + } else { + ReadContext::Rules + }, + ); + self.visit_expression(&mut it.value); + self.context = context; + self.source = outer; + self.visit_property_key(&mut it.key); } } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index a9a43a80e..732f45784 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -5,9 +5,9 @@ use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ ast::{ - Argument, CallExpression, Expression, ExpressionStatement, IdentifierName, - JSXAttributeValue, ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, - PropertyKey, Statement, StaticMemberExpression, + Argument, ArrayExpressionElement, CallExpression, Expression, ExpressionStatement, + IdentifierName, JSXAttributeValue, ObjectExpression, ObjectProperty, ObjectPropertyKind, + Program, PropertyKey, Statement, StaticMemberExpression, }, builder::AstBuilder, }; @@ -1062,7 +1062,16 @@ fn collect_style_parts<'b, 'a>( match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => { for element in &array.elements { - collect_style_parts(ast_builder, element.as_expression()?, parts, classes)?; + let expression = match element { + ArrayExpressionElement::SpreadElement(spread) => { + match unwrap_syntax_only(&spread.argument) { + array @ Expression::ArrayExpression(_) => array, + _ => return None, + } + } + element => element.as_expression()?, + }; + collect_style_parts(ast_builder, expression, parts, classes)?; } return Some(()); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 8930d110b..3ac8bb37d 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -201,7 +201,7 @@ pub struct DevupVisitor<'a> { /// name of their own runtime_types: usize, /// The classes and keyframes names the file binds to a `const` - style_values: crate::style_values::StyleValues, + style_values: crate::style_values::StyleValues<'a>, /// The styles of the last `css()` call giving a class, by where it starts, /// for the `const` it initializes css_styles: Option<(u32, Vec)>, @@ -600,7 +600,7 @@ impl<'a> DevupVisitor<'a> { } match expression { Expression::ObjectExpression(_) => Some(KnownSide::Styles(vec![KnownStyles::Rules( - expression.clone_in(self.ast.allocator()), + expression.clone_in_with_semantic_ids(self.ast.allocator()), )])), Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => Some(KnownSide::Empty), Expression::Identifier(identifier) if identifier.name == "undefined" => { @@ -2287,15 +2287,28 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } + if let Expression::CallExpression(call) = it + && self + .util_type(&call.callee) + .is_some_and(|util| util.is_css()) + { + self.style_values + .record_rules(&self.ast, call.span.start, &call.arguments); + } + if let Expression::CallExpression(call) = it && self .util_type(&call.callee) .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) - && let Some(composed) = self.compose_known_styles(call) + && let Some(mut composed) = self.compose_known_styles(call) { + if let Expression::StringLiteral(text) = &mut composed { + text.span = call.span; + } *it = composed; } else if let Expression::CallExpression(call) = it { if let Some(util_type) = self.util_type(&call.callee) { + let source_span = call.span; for argument in &mut call.arguments { let expression = match argument { Argument::SpreadElement(spread) => &mut spread.argument, @@ -2550,6 +2563,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); *it = flatten_classes(&self.ast, classes); } + if util_type.is_css() + && let Expression::StringLiteral(text) = it + { + text.span = source_span; + } } } else if let Expression::TaggedTemplateExpression(tag) = it && let Some(class_type) = self.util_type(&tag.tag) @@ -2953,6 +2971,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_variable_declarator(self, it); + if let Some(symbol) = self.style_values.constant(&it.id) + && let Some(value) = &it.init + { + self.style_values.bind_rules(&self.ast, symbol, value); + } + if let Some((at, definition)) = self.pending_styled.take() && Some(at) == start && let Some(symbol) = styled_binding