From 050c6862053346cd5f4847ea91399f59dd87491b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 18:10:25 +0900 Subject: [PATCH 1/2] fix(extractor): read constants in Emotion rules as px and compose mixins and inline css() by styles Refs #688, #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_emotion_rule_numbers.json | 7 + libs/extractor/src/build_time_values.rs | 1 + libs/extractor/src/import_alias_visit.rs | 68 +++++++- libs/extractor/src/imported_constants.rs | 41 +++-- libs/extractor/src/lib.rs | 124 +++++++++++++ ...emotion_css_flattens_inline_css_calls.snap | 50 ++++++ ...on_css_text_composes_mixins_by_styles.snap | 42 +++++ ...les_read_imported_constants_as_pixels.snap | 26 +++ ..._rules_read_local_constants_as_pixels.snap | 54 ++++++ ...actor__tests__template_interpolations.snap | 2 +- libs/extractor/src/visit.rs | 163 +++++++++++++++++- 11 files changed, 548 insertions(+), 30 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_rule_numbers.json create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_text_composes_mixins_by_styles.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_imported_constants_as_pixels.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_local_constants_as_pixels.snap diff --git a/.changepacks/changepack_log_emotion_rule_numbers.json b/.changepacks/changepack_log_emotion_rule_numbers.json new file mode 100644 index 000000000..f12449dd0 --- /dev/null +++ b/.changepacks/changepack_log_emotion_rule_numbers.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Numbers in rules written against Emotion, styled-components or vanilla-extract now read as px lengths when a constant stands in for the literal, as they do for a literal written in place: css({ padding: gap }) with const gap = 8, styled.div({ margin: SIZE }), and the same with imported constants and constant style objects give 8px, while unitless properties keep bare numbers, 0 stays 0 and Devup shorthands such as p keep the spacing scale. css`${mixin}; color: red;` outside the css prop composes the mixin by styles, so a later declaration replaces the mixin's, and a css`...` template is a known style a later css() composes. Inline css() calls inside css() arguments (css(css({ ... }), { ... }), css(cond && css({ ... }), base)) are composed with the other parts, the later winning, instead of being compiled to a class of their own. No new build errors", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index f9d85cdf9..c7156bbf7 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -74,6 +74,7 @@ pub(crate) fn has_build_time_values( option, resolver, aliased.css_prop, + &aliased.px, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index db6e569c8..83f55d76d 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -58,9 +58,17 @@ struct LibraryNumbers<'n> { /// `css` taking rules class_names: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, + /// Where a constant inlined later is a number or rules of the library, as + /// the source reads them + marks: Vec<(usize, usize)>, } impl LibraryNumbers<'_> { + fn mark(expression: &Expression) -> (usize, usize) { + let span = expression.span(); + (span.start as usize, span.end as usize) + } + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules fn is_styled_factory(&self, callee: &Expression) -> bool { @@ -91,10 +99,15 @@ impl LibraryNumbers<'_> { } Expression::ObjectExpression(object) => { for property in &object.properties { - if let ObjectPropertyKind::ObjectProperty(property) = property - && let Some(key) = get_str_by_property_key(&property.key) - { - self.pixelify_value(&key, &property.value); + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = get_str_by_property_key(&property.key) { + self.pixelify_value(&key, &property.value); + } + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.marks.push(Self::mark(&spread.argument)); + } } } } @@ -104,7 +117,7 @@ impl LibraryNumbers<'_> { } Expression::LogicalExpression(logical) => self.pixelify(&logical.right), Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression), - _ => {} + rules => self.marks.push(Self::mark(rules)), } } @@ -180,6 +193,9 @@ impl LibraryNumbers<'_> { } return; } + if !matches!(value, Expression::ObjectExpression(_)) && !keeps_bare_number(key) { + self.marks.push(Self::mark(value)); + } match value { Expression::ObjectExpression(_) => { if key != "vars" { @@ -190,7 +206,12 @@ impl LibraryNumbers<'_> { self.pixelify_value(key, &conditional.consequent); self.pixelify_value(key, &conditional.alternate); } - Expression::LogicalExpression(logical) => self.pixelify_value(key, &logical.right), + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_value(key, &logical.left); + } + self.pixelify_value(key, &logical.right); + } Expression::ParenthesizedExpression(inner) => { self.pixelify_value(key, &inner.expression); } @@ -343,8 +364,16 @@ pub struct Aliased<'a> { pub edits: Vec, /// Which elements take Emotion's `css` prop pub css_prop: CssProp, + /// Where, in `code`, a constant read is a number or rules the library + /// reads as `px` lengths + pub px: PxMarks, } +/// The spans of the style values and rules written as an expression a +/// constant can stand in, which Emotion, styled-components and vanilla-extract +/// read numbers in as `px` lengths +pub type PxMarks = rustc_hash::FxHashSet<(u32, u32)>; + /// A replacement of `code[start..end]` by text of `length` bytes pub type Edit = (usize, usize, usize); @@ -366,10 +395,11 @@ pub fn transform_import_aliases_with_edits<'a>( package: &str, import_aliases: &HashMap, ) -> Aliased<'a> { - let unchanged = |css_prop| Aliased { + let unchanged = |css_prop, px| Aliased { code: Cow::Borrowed(code), edits: Vec::new(), css_prop, + px, }; let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); let emotion_jsx = builds_jsx_with_emotion(import_aliases); @@ -379,7 +409,7 @@ pub fn transform_import_aliases_with_edits<'a>( && CssProp::of(import_aliases, code, false) == CssProp::Off && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) { - return unchanged(CssProp::Off); + return unchanged(CssProp::Off, PxMarks::default()); } let allocator = Allocator::default(); @@ -512,13 +542,32 @@ pub fn transform_import_aliases_with_edits<'a>( // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return unchanged(numbers.css_prop); + let px = numbers + .marks + .iter() + .map(|&(start, end)| (start as u32, end as u32)) + .collect(); + return unchanged(numbers.css_prop, px); } let edits = transformations .iter() .map(|(start, end, replacement)| (*start, *end, replacement.len())) .collect(); + let shifted = |offset: usize| { + let before = transformations + .iter() + .take_while(|(_, end, _)| *end <= offset); + let (added, removed) = before.fold((0, 0), |(added, removed), (start, end, text)| { + (added + text.len(), removed + end - start) + }); + (offset + added - removed) as u32 + }; + let px = numbers + .marks + .iter() + .map(|&(start, end)| (shifted(start), shifted(end))) + .collect(); let mut result = code.to_string(); for (start, end, replacement) in transformations.into_iter().rev() { result.replace_range(start..end, &replacement); @@ -528,6 +577,7 @@ pub fn transform_import_aliases_with_edits<'a>( code: Cow::Owned(result), edits, css_prop: numbers.css_prop, + px, } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 03cb5c9b5..05e0ddad0 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -15,7 +15,7 @@ use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut}; use oxc_parser::Parser; use oxc_semantic::{Scoping, SemanticBuilder}; -use oxc_span::{SPAN, SourceType}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::number::NumberBase; use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; @@ -322,6 +322,7 @@ pub(crate) fn inline_constants<'a>( option: &ExtractOption, resolver: Option<&ModuleResolver>, css_prop: CssProp, + px: &crate::import_alias_visit::PxMarks, ) -> Inlined { let is_style_package = |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; @@ -510,6 +511,7 @@ pub(crate) fn inline_constants<'a>( objects: false, styles: false, px: false, + marks: px, class_names: Vec::new(), } .visit_program(program); @@ -1812,6 +1814,9 @@ struct Inline<'s, 'a> { styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, + /// Where a constant stands as a value or rules of a library reading + /// numbers as `px` lengths + marks: &'s crate::import_alias_visit::PxMarks, /// The names the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -1980,16 +1985,12 @@ impl<'a> Inline<'_, 'a> { } } -/// `property` holding a number as the `px` length Emotion reads it as -fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { - if let Some(number) = crate::utils::js_number_literal(&property.value) +/// `value` as the `px` length Emotion reads it as when it is a number +fn px_number<'a>(ast_builder: &AstBuilder<'a>, value: &mut Expression<'a>) { + if let Some(number) = crate::utils::js_number_literal(value) && number != 0.0 - && property - .key - .static_name() - .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) { - property.value = Expression::new_string_literal( + *value = Expression::new_string_literal( SPAN, Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), None, @@ -1998,12 +1999,25 @@ fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::Objec } } +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + px_number(ast_builder, &mut property.value); + } +} + /// The rules `rules` with their numbers as the `px` lengths Emotion reads them /// as, nested rules included fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { if let Expression::ObjectExpression(object) = rules { for property in &mut object.properties { - if let ObjectPropertyKind::ObjectProperty(property) = property { + if let ObjectPropertyKind::ObjectProperty(property) = property + && property.key.static_name().is_none_or(|key| key != "vars") + { px_value(ast_builder, property); px_rules(ast_builder, &mut property.value); } @@ -2072,8 +2086,13 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { .constant(expression) .and_then(|constant| self.literal(&constant)) { + let span = expression.span(); + let marked = self.marks.contains(&(span.start, span.end)); *expression = literal; - if self.px { + if marked { + px_number(self.ast_builder, expression); + } + if self.px || marked { px_rules(self.ast_builder, expression); } return; diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ac1dcbde6..06a866d2c 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -305,6 +305,7 @@ fn extract_source( code: transformed_code, edits: alias_edits, css_prop, + px, } = import_alias_visit::transform_import_aliases_with_edits( code, filename, @@ -435,6 +436,7 @@ fn extract_source( &option, resolver, css_prop, + &px, ) } else { imported_constants::Inlined::default() @@ -21552,4 +21554,126 @@ export const App = () => ExtractOption { + let mut option = emotion_option(); + option.import_aliases.insert( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ); + option.import_aliases.insert( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + ); + option + } + + const SIZE_MODULES: &[(&str, &str)] = &[ + ( + "/src/sizes.ts", + "export const SIZE = 12;\nexport const ZERO = 0;\nexport const LINE = 2;\nexport const RULES = { padding: 5, lineHeight: 2, vars: { gap: 3 } };\nexport const NAME = 'big';", + ), + ("/src/App.tsx", ""), + ]; + + fn library_outputs( + cases: &[&str], + modules: &'static [(&'static str, &'static str)], + ) -> Vec<(String, String)> { + cases + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + let output = match extract_with_modules( + "/src/App.tsx", + code, + library_option(), + false, + &memory_resolver(modules), + ) { + Ok(output) => format!("{:?}", ToBTreeSet::from(output)), + Err(error) => format!("Error: {error}"), + }; + ((*code).to_string(), output) + }) + .collect() + } + + #[test] + #[serial] + fn test_emotion_rules_read_local_constants_as_pixels() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nconst gap = 8;\nconst ZERO = 0;\nconst LINE = 2;\nexport const a = css({ padding: gap, margin: ZERO, lineHeight: LINE, opacity: LINE, p: gap, mx: gap });", + "import { css } from '@emotion/react';\nconst gap = 8;\nexport const a = css({ '&:hover': { top: -gap, left: gap * 2 }, width: gap > 4 ? gap : 1, height: gap || 3, minWidth: (gap) });", + "import { css } from '@emotion/react';\nconst gap = 8;\nconst rules = { padding: gap, lineHeight: 2, vars: { margin: 3 } };\nconst nested = { '&:hover': rules };\nexport const a = css(rules);\nexport const b = css({ ...rules, margin: 1 });\nexport const c = css({ '&:focus': rules });\nexport const d = css(nested);", + "import { css } from '@emotion/react';\nconst gap = 8;\nconst label = 'a';\nexport const a = css({ padding: gap, content: label, margin: label });", + "import styled from '@emotion/styled';\nconst gap = 8;\nexport const A = styled.div({ margin: gap, flex: gap, zIndex: gap });\nexport const B = styled('div')({ margin: gap });", + "import styled, { css } from 'styled-components';\nconst gap = 8;\nexport const A = styled.div({ margin: gap });\nexport const B = css({ margin: gap });", + "import { Global } from '@emotion/react';\nconst gap = 8;\nexport const App = () => ;", + "import { ClassNames } from '@emotion/react';\nconst gap = 8;\nexport const App = () => {({ css }) => };", + "import { style } from '@vanilla-extract/css';\nconst gap = 8;\nexport const a = style({ margin: gap, lineHeight: gap, vars: { x: gap } });", + "import { css } from '@emotion/react';\nconst gap = 8;\nexport const App = () =>
;", + "import { css } from '@devup-ui/react';\nconst gap = 8;\nexport const a = css({ padding: gap, margin: 8 });", + "import { css } from '@emotion/react';\nexport const a = css({ padding: unknown, margin: 8 });", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_emotion_rules_read_imported_constants_as_pixels() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nimport { SIZE, ZERO, LINE, RULES, NAME } from './sizes';\nexport const a = css({ padding: SIZE, margin: ZERO, lineHeight: LINE, p: SIZE, content: NAME });\nexport const b = css(RULES);\nexport const c = css({ ...RULES, margin: SIZE });", + "import styled from '@emotion/styled';\nimport { SIZE } from './sizes';\nexport const A = styled.div({ margin: SIZE });", + "import { Global } from '@emotion/react';\nimport { SIZE } from './sizes';\nexport const App = () => ;", + "import { style } from '@vanilla-extract/css';\nimport { SIZE } from './sizes';\nexport const a = style({ margin: SIZE });", + "import { css } from '@devup-ui/react';\nimport { SIZE } from './sizes';\nexport const a = css({ padding: SIZE });", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_emotion_css_text_composes_mixins_by_styles() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nconst mixin = css`color: red; margin: 1px;`;\nexport const a = css`${mixin}; color: blue;`;", + "import { css } from '@emotion/react';\nconst mixin = css({ color: 'red', margin: 1 });\nexport const a = css`color: green; ${mixin}; padding: 2px;`;", + "import { css } from '@emotion/react';\nconst mixin = css`color: red;`;\nconst more = css`${mixin}; margin: 2px;`;\nexport const a = css`${more}; color: blue;`;\nexport const b = css(more, { color: 'green' });", + "import { css } from '@emotion/react';\nexport const a = ({ mixin }) => css`${mixin}; color: blue;`;", + "import { css } from '@emotion/react';\nconst mixin = css`color: red;`;\nexport const a = ({ other }) => css`${mixin}; ${other}; color: blue;`;", + "import { css } from '@emotion/react';\nexport const a = css`${css({ color: 'red' })}; color: blue;`;", + "import { css } from '@emotion/react';\nexport const a = ({ on }) => css`${on && css({ color: 'red', margin: 1 })}; color: blue;`;", + "import { css } from '@emotion/react';\nexport const a = css`${css`color: red;`}; color: blue;`;", + "import { css } from '@emotion/react';\nexport const a = ({ on }) => css`${on ? css`color: red;` : css({ margin: 1 })}; color: blue;`;", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_emotion_css_flattens_inline_css_calls() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }), { color: 'blue' });", + "import { css } from '@emotion/react';\nconst base = css({ color: 'green', margin: 4 });\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);", + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond ? css({ color: 'red' }) : null, { color: 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond || css({ color: 'red' }), { margin: 1 });", + "import { css } from '@emotion/react';\nexport const a = css([css({ color: 'red' }), [css({ margin: 1 }), { color: 'blue' }]]);", + "import { css } from '@emotion/react';\nexport const a = css(css(css({ color: 'red' }), { margin: 1 }), { color: 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [css({ color: 'red' }), { margin: 1 }], { color: 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (rest) => css(css(...rest), { color: 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (cond, cls) => css(css({ color: 'red' }), cls, cond && css({ color: 'blue' }));", + "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }));", + "import { css } from '@devup-ui/react';\nexport const a = css(css({ color: 'red' }), { color: 'blue' });", + ], + SIZE_MODULES + )); + } } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap new file mode 100644 index 000000000..10b1d1f72 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap @@ -0,0 +1,50 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport const a = css(css({ color: 'red' }), { color: 'blue' });\",\n\"import { css } from '@emotion/react';\\nconst base = css({ color: 'green', margin: 4 });\\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond) => css(cond ? css({ color: 'red' }) : null, { color: 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond) => css(cond || css({ color: 'red' }), { margin: 1 });\",\n\"import { css } from '@emotion/react';\\nexport const a = css([css({ color: 'red' }), [css({ margin: 1 }), { color: 'blue' }]]);\",\n\"import { css } from '@emotion/react';\\nexport const a = css(css(css({ color: 'red' }), { margin: 1 }), { color: 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond) => css(cond && [css({ color: 'red' }), { margin: 1 }], { color: 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (rest) => css(css(...rest), { color: 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond, cls) => css(css({ color: 'red' }), cls, cond && css({ color: 'blue' }));\",\n\"import { css } from '@emotion/react';\\nexport const a = css(css({ color: 'red' }));\",\n\"import { css } from '@devup-ui/react';\\nexport const a = css(css({ color: 'red' }), { color: 'blue' });\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }), { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst base = css({ color: 'green', margin: 4 });\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst base = \\\"a b\\\";\\nexport const a = (cond) => \\\"b a\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond ? css({ color: 'red' }) : null, { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => \\\"a\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond || css({ color: 'red' }), { margin: 1 });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => `${cond ? typeof cond === \\\"string\\\" ? cond : \\\"\\\" : \\\"\\\"} ${`b ${cond ? \\\"\\\" : \\\"a\\\"}`}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css([css({ color: 'red' }), [css({ margin: 1 }), { color: 'blue' }]]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a b\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css(css(css({ color: 'red' }), { margin: 1 }), { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a b\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [css({ color: 'red' }), { margin: 1 }], { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => `b ${cond ? \\\"a\\\" : \\\"\\\"}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (rest) => css(css(...rest), { color: 'blue' });", + "Error: /src/App.tsx:2:32: Cannot compose `...rest` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond, cls) => css(css({ color: 'red' }), cls, cond && css({ color: 'blue' }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond, cls) => `${cls} ${cond ? \\\"a\\\" : \\\"b\\\"}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), + ( + "import { css } from '@devup-ui/react';\nexport const a = css(css({ color: 'red' }), { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_text_composes_mixins_by_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_text_composes_mixins_by_styles.snap new file mode 100644 index 000000000..9c407a429 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_text_composes_mixins_by_styles.snap @@ -0,0 +1,42 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nconst mixin = css`color: red; margin: 1px;`;\\nexport const a = css`${mixin}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nconst mixin = css({ color: 'red', margin: 1 });\\nexport const a = css`color: green; ${mixin}; padding: 2px;`;\",\n\"import { css } from '@emotion/react';\\nconst mixin = css`color: red;`;\\nconst more = css`${mixin}; margin: 2px;`;\\nexport const a = css`${more}; color: blue;`;\\nexport const b = css(more, { color: 'green' });\",\n\"import { css } from '@emotion/react';\\nexport const a = ({ mixin }) => css`${mixin}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nconst mixin = css`color: red;`;\\nexport const a = ({ other }) => css`${mixin}; ${other}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nexport const a = css`${css({ color: 'red' })}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nexport const a = ({ on }) => css`${on && css({ color: 'red', margin: 1 })}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nexport const a = css`${css`color: red;`}; color: blue;`;\",\n\"import { css } from '@emotion/react';\\nexport const a = ({ on }) => css`${on ? css`color: red;` : css({ margin: 1 })}; color: blue;`;\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nconst mixin = css`color: red; margin: 1px;`;\nexport const a = css`${mixin}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst mixin = \\\"a b\\\";\\nexport const a = \\\"a c\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst mixin = css({ color: 'red', margin: 1 });\nexport const a = css`color: green; ${mixin}; padding: 2px;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst mixin = \\\"a b\\\";\\nexport const a = \\\"b a c\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst mixin = css`color: red;`;\nconst more = css`${mixin}; margin: 2px;`;\nexport const a = css`${more}; color: blue;`;\nexport const b = css(more, { color: 'green' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst mixin = \\\"a\\\";\\nconst more = \\\"a b\\\";\\nexport const a = \\\"b c\\\";\\nexport const b = \\\"b d\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = ({ mixin }) => css`${mixin}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = ({ mixin }) => `a ${mixin}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst mixin = css`color: red;`;\nexport const a = ({ other }) => css`${mixin}; ${other}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst mixin = \\\"a\\\";\\nexport const a = ({ other }) => `b ${other}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css`${css({ color: 'red' })}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = ({ on }) => css`${on && css({ color: 'red', margin: 1 })}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = ({ on }) => `b ${on ? \\\"a\\\" : \\\"\\\"}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css`${css`color: red;`}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = \\\"a b\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = ({ on }) => css`${on ? css`color: red;` : css({ margin: 1 })}; color: blue;`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = ({ on }) => `${on ? \\\"a\\\" : \\\"\\\"} ${`c ${on ? \\\"\\\" : \\\"b\\\"}`}`;\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_imported_constants_as_pixels.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_imported_constants_as_pixels.snap new file mode 100644 index 000000000..29a570bdd --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_imported_constants_as_pixels.snap @@ -0,0 +1,26 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nimport { SIZE, ZERO, LINE, RULES, NAME } from './sizes';\\nexport const a = css({ padding: SIZE, margin: ZERO, lineHeight: LINE, p: SIZE, content: NAME });\\nexport const b = css(RULES);\\nexport const c = css({ ...RULES, margin: SIZE });\",\n\"import styled from '@emotion/styled';\\nimport { SIZE } from './sizes';\\nexport const A = styled.div({ margin: SIZE });\",\n\"import { Global } from '@emotion/react';\\nimport { SIZE } from './sizes';\\nexport const App = () => ;\",\n\"import { style } from '@vanilla-extract/css';\\nimport { SIZE } from './sizes';\\nexport const a = style({ margin: SIZE });\",\n\"import { css } from '@devup-ui/react';\\nimport { SIZE } from './sizes';\\nexport const a = css({ padding: SIZE });\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nimport { SIZE, ZERO, LINE, RULES, NAME } from './sizes';\nexport const a = css({ padding: SIZE, margin: ZERO, lineHeight: LINE, p: SIZE, content: NAME });\nexport const b = css(RULES);\nexport const c = css({ ...RULES, margin: SIZE });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"content\", value: \"big\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"gap\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"0\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"48px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"5px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { SIZE, ZERO, LINE, RULES, NAME } from \\\"./sizes\\\";\\nexport const a = \\\"a b c d e\\\";\\nexport const b = \\\"f c g\\\";\\nexport const c = \\\"f c g h\\\";\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nimport { SIZE } from './sizes';\nexport const A = styled.div({ margin: SIZE });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nimport { SIZE } from \\\"./sizes\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import { Global } from '@emotion/react';\nimport { SIZE } from './sizes';\nexport const App = () => ;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { Global } from \\\"@devup-ui/react/compat\\\";\\nimport { SIZE } from \\\"./sizes\\\";\\nexport const App = () => ;\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nimport { SIZE } from './sizes';\nexport const a = style({ margin: SIZE });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { SIZE } from \\\"./sizes\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), + ( + "import { css } from '@devup-ui/react';\nimport { SIZE } from './sizes';\nexport const a = css({ padding: SIZE });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"48px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { SIZE } from \\\"./sizes\\\";\\nexport const a = \\\"a\\\";\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_local_constants_as_pixels.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_local_constants_as_pixels.snap new file mode 100644 index 000000000..200f27f51 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_rules_read_local_constants_as_pixels.snap @@ -0,0 +1,54 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nconst gap = 8;\\nconst ZERO = 0;\\nconst LINE = 2;\\nexport const a = css({ padding: gap, margin: ZERO, lineHeight: LINE, opacity: LINE, p: gap, mx: gap });\",\n\"import { css } from '@emotion/react';\\nconst gap = 8;\\nexport const a = css({ '&:hover': { top: -gap, left: gap * 2 }, width: gap > 4 ? gap : 1, height: gap || 3, minWidth: (gap) });\",\n\"import { css } from '@emotion/react';\\nconst gap = 8;\\nconst rules = { padding: gap, lineHeight: 2, vars: { margin: 3 } };\\nconst nested = { '&:hover': rules };\\nexport const a = css(rules);\\nexport const b = css({ ...rules, margin: 1 });\\nexport const c = css({ '&:focus': rules });\\nexport const d = css(nested);\",\n\"import { css } from '@emotion/react';\\nconst gap = 8;\\nconst label = 'a';\\nexport const a = css({ padding: gap, content: label, margin: label });\",\n\"import styled from '@emotion/styled';\\nconst gap = 8;\\nexport const A = styled.div({ margin: gap, flex: gap, zIndex: gap });\\nexport const B = styled('div')({ margin: gap });\",\n\"import styled, { css } from 'styled-components';\\nconst gap = 8;\\nexport const A = styled.div({ margin: gap });\\nexport const B = css({ margin: gap });\",\n\"import { Global } from '@emotion/react';\\nconst gap = 8;\\nexport const App = () => ;\",\n\"import { ClassNames } from '@emotion/react';\\nconst gap = 8;\\nexport const App = () => {({ css }) => };\",\n\"import { style } from '@vanilla-extract/css';\\nconst gap = 8;\\nexport const a = style({ margin: gap, lineHeight: gap, vars: { x: gap } });\",\n\"import { css } from '@emotion/react';\\nconst gap = 8;\\nexport const App = () =>
;\",\n\"import { css } from '@devup-ui/react';\\nconst gap = 8;\\nexport const a = css({ padding: gap, margin: 8 });\",\n\"import { css } from '@emotion/react';\\nexport const a = css({ padding: unknown, margin: 8 });\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nconst gap = 8;\nconst ZERO = 0;\nconst LINE = 2;\nexport const a = css({ padding: gap, margin: ZERO, lineHeight: LINE, opacity: LINE, p: gap, mx: gap });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"0\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin-left\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin-right\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"opacity\", value: \"2\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nconst ZERO = 0;\\nconst LINE = 2;\\nexport const a = \\\"a b c d e f g\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst gap = 8;\nexport const a = css({ '&:hover': { top: -gap, left: gap * 2 }, width: gap > 4 ? gap : 1, height: gap || 3, minWidth: (gap) });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"height\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"left\", value: \"16px\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"min-width\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"top\", value: \"-8px\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nexport const a = `a b e f ${8 > 4 ? \\\"c\\\" : \\\"d\\\"}`;\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst gap = 8;\nconst rules = { padding: gap, lineHeight: 2, vars: { margin: 3 } };\nconst nested = { '&:hover': rules };\nexport const a = css(rules);\nexport const b = css({ ...rules, margin: 1 });\nexport const c = css({ '&:focus': rules });\nexport const d = css(nested);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: Some(Selector(\"&:focus\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"12px\", level: 0, selector: Some(Selector(\"&:focus\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: Some(Selector(\"&:focus\")), style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nconst rules = {\\n\\tpadding: gap,\\n\\tlineHeight: 2,\\n\\tvars: { margin: 3 }\\n};\\nconst nested = { \\\"&:hover\\\": rules };\\nexport const a = \\\"a b c\\\";\\nexport const b = \\\"a b c d\\\";\\nexport const c = \\\"e f g\\\";\\nexport const d = \\\"h i j\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst gap = 8;\nconst label = 'a';\nexport const a = css({ padding: gap, content: label, margin: label });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"content\", value: \"a\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"a\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nconst label = \\\"a\\\";\\nexport const a = \\\"a b c\\\";\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nconst gap = 8;\nexport const A = styled.div({ margin: gap, flex: gap, zIndex: gap });\nexport const B = styled('div')({ margin: gap });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"flex\", value: \"8\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"z-index\", value: \"8\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nconst gap = 8;\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b c\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled, { css } from 'styled-components';\nconst gap = 8;\nexport const A = styled.div({ margin: gap });\nexport const B = css({ margin: gap });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nconst gap = 8;\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = \\\"a\\\";\\n\" }", + ), + ( + "import { Global } from '@emotion/react';\nconst gap = 8;\nexport const App = () => ;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"line-height\", value: \"2\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { Global } from \\\"@devup-ui/react/compat\\\";\\nconst gap = 8;\\nexport const App = () => ;\\n\" }", + ), + ( + "import { ClassNames } from '@emotion/react';\nconst gap = 8;\nexport const App = () => {({ css }) => };", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nexport const App = () => ;\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nconst gap = 8;\nexport const a = style({ margin: gap, lineHeight: gap, vars: { x: gap } });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"line-height\", value: \"8\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"x\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nexport const a = \\\"a b c\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nconst gap = 8;\nexport const App = () =>
;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nexport const App = () =>
;\\n\" }", + ), + ( + "import { css } from '@devup-ui/react';\nconst gap = 8;\nexport const a = css({ padding: gap, margin: 8 });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"32px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst gap = 8;\\nexport const a = \\\"a b\\\";\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = css({ padding: unknown, margin: 8 });", + "Error: /src/App.tsx:2:18: `css()` cannot use `unknown` at build time: its values must be literals, theme tokens or constants, or be computed from them", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap b/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap index 688c87467..2f9bbfb3d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap @@ -97,5 +97,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst C = \"red\";\nconst O = .5;\nconst SEL = \".x\";\nconst SNIPPET = \"display: flex;\";\nconst mixin = \"a-a\";\n;\n;\nexport const k = \"a-b\";\nexport const c = \"a-c a-d\";\nexport const d = `a-e ${mixin}`;\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[`a-d ${mixin} ${((p) => p.active && mixin)(rest) || \"\"}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst C = \"red\";\nconst O = .5;\nconst SEL = \".x\";\nconst SNIPPET = \"display: flex;\";\nconst mixin = \"a-a\";\n;\n;\nexport const k = \"a-b\";\nexport const c = \"a-c a-d\";\nexport const d = \"a-a a-e\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[`a-d ${mixin} ${((p) => p.active && mixin)(rest) || \"\"}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f78d276d3..f4ae87dd7 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -390,7 +390,11 @@ impl<'a> DevupVisitor<'a> { /// `css(...)` composing a class whose styles the build knows: the parts' /// styles merge, a later declaration replacing an earlier one. `None` when /// no part is such a class, or a part is one composing does not read. - fn compose_known_styles(&mut self, call: &CallExpression<'a>) -> Option> { + fn compose_known_styles( + &mut self, + call: &CallExpression<'a>, + flattened: bool, + ) -> Option> { let arguments: Vec<&Expression<'a>> = call .arguments .iter() @@ -399,9 +403,10 @@ impl<'a> DevupVisitor<'a> { argument => argument.to_expression(), }) .collect(); - if !arguments - .iter() - .any(|argument| self.reads_known_styles(argument)) + if !flattened + && !arguments + .iter() + .any(|argument| self.reads_known_styles(argument)) { return None; } @@ -418,6 +423,106 @@ impl<'a> DevupVisitor<'a> { Some(result) } + /// Whether `callee` is a call of `css()` + fn is_css(&self, callee: &Expression<'a>) -> bool { + self.util_type(callee) + .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) + } + + /// `part` of what `css()` or CSS text composes, with each inline `css()` + /// call in it written as the array of what it composes, so that what it + /// composes is ordered with the rest rather than compiled to a class of + /// its own, and with `templates`, each inline CSS text in it written as an + /// array of itself, which keeps the class it compiles to from being read as + /// CSS text. Whether a part was written so. + fn flatten_css_calls(&self, part: &mut Expression<'a>, templates: bool) -> bool { + if templates + && matches!(unwrap_syntax_only(part), Expression::TaggedTemplateExpression(tag) + if self.is_css(&tag.tag)) + { + let template = part.take_in(&self.ast); + *part = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_array_in([template.into()], &self.ast), + &self.ast, + ); + return true; + } + let array = match unwrap_syntax_only_mut(part) { + Expression::ArrayExpression(array) => { + return array.elements.iter_mut().fold(false, |flattened, part| { + part.as_expression_mut() + .is_some_and(|part| self.flatten_css_calls(part, templates)) + | flattened + }); + } + Expression::ConditionalExpression(conditional) => { + return self.flatten_css_calls(&mut conditional.consequent, templates) + | self.flatten_css_calls(&mut conditional.alternate, templates); + } + Expression::LogicalExpression(logical) => { + return (logical.operator != LogicalOperator::And + && self.flatten_css_calls(&mut logical.left, templates)) + | self.flatten_css_calls(&mut logical.right, templates); + } + Expression::CallExpression(call) + if self.is_css(&call.callee) + && call + .arguments + .iter() + .all(|argument| !matches!(argument, Argument::SpreadElement(_))) => + { + let elements = call + .arguments + .drain(..) + .map(|argument| argument.into_expression().into()); + Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(elements, &self.ast), + &self.ast, + ) + } + _ => return false, + }; + *part = array; + self.flatten_css_calls(part, templates); + true + } + + /// `css` with CSS text composing mixins whose styles the build knows, as + /// the parts around them: the styles merge, a later declaration replacing + /// an earlier one. `None` when no mixin is such a class, or the text is + /// not read as parts. + fn compose_template( + &mut self, + tag: &oxc_ast::ast::TaggedTemplateExpression<'a>, + flattened: bool, + ) -> Option> { + let texts = template_parts(&self.ast, &tag.quasi, true).ok()?; + let is_mixin = |text: &Expression<'a>| !matches!(text, Expression::TemplateLiteral(_)); + if !texts + .iter() + .any(|text| is_mixin(text) && (flattened || self.reads_known_styles(text))) + { + return None; + } + let mut parts = Vec::new(); + for text in &texts { + let kind = if is_mixin(text) { + Text::Classes + } else { + Text::Rules + }; + self.known_parts(text, &mut parts, kind)?; + } + let offset = tag.span.start; + let (result, known) = self.composed_class(offset, parts); + if let Some(known) = known { + self.css_styles = Some((offset, known)); + } + Some(result) + } + /// The classes `parts`, composed at `offset`, give, with the styles they /// always set when no class the build does not know joins them fn composed_class( @@ -636,9 +741,7 @@ impl<'a> DevupVisitor<'a> { Expression::CallExpression(call) if self.class_names_text(&call.callee).is_some() => { self.folded_side(expression, text) } - Expression::ArrayExpression(_) if !self.class_names_scope.is_empty() => { - self.folded_side(expression, text) - } + Expression::ArrayExpression(_) => self.folded_side(expression, text), _ => None, } } @@ -2387,6 +2490,25 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + let flattened = match it { + Expression::CallExpression(call) if self.is_css(&call.callee) => call + .arguments + .iter_mut() + .fold(false, |flattened, argument| { + argument + .as_expression_mut() + .is_some_and(|part| self.flatten_css_calls(part, false)) + | flattened + }), + Expression::TaggedTemplateExpression(tag) if self.is_css(&tag.tag) => tag + .quasi + .expressions + .iter_mut() + .fold(false, |flattened, part| { + self.flatten_css_calls(part, true) | flattened + }), + _ => false, + }; walk_expression(self, it); // Handle styled function calls @@ -2895,7 +3017,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && 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(composed) = self.compose_known_styles(call, flattened) { *it = composed; } else if let Expression::CallExpression(call) = it { @@ -3134,13 +3256,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { UtilType::Keyframes => "keyframes", UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", }; + let composed = if matches!(r, UtilType::Css) { + self.compose_template(tag, flattened) + } else { + None + }; let mut build_css_str = || { template_css_text(&tag.quasi, api).unwrap_or_else(|error| { self.errors.push(error); String::new() }) }; - *it = if matches!(r, UtilType::Css) { + *it = if let Some(composed) = composed { + composed + } else if matches!(r, UtilType::Css) { let TemplateStyles { styles, statements, @@ -3159,12 +3288,28 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.errors .push((expression.span().start, unplaced_error(expression))); } + let known = statements.is_empty().then(|| { + let mut known = crate::composition::Composition::default(); + known.apply( + &self.ast, + style_props + .iter() + .map(|prop| prop.clone_in(self.ast.allocator())) + .collect(), + ); + known.unconditional() + }); let class_name = gen_class_names( &self.ast, &mut style_props, None, self.split_filename.as_deref(), ); + if let Some(Some(known)) = known + && matches!(class_name, Some(Expression::StringLiteral(_))) + { + self.css_styles = Some((tag.span.start, known)); + } for ex in style_props { // every entry was built as `Static` above From a2932bc496396c43fa437eca5f279625dbcd1150 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 20 Oct 2026 14:17:31 +0900 Subject: [PATCH 2/2] fix(extractor): drop the uncovered fallthrough arm of px_rules Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/imported_constants.rs | 34 +++++++++++------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 929931e9c..062a46272 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -2204,28 +2204,24 @@ fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::Objec fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { let mut pending = vec![rules]; while let Some(rules) = pending.pop() { - match rules { - Expression::ObjectExpression(object) => { - for property in &mut object.properties { - if let ObjectPropertyKind::ObjectProperty(property) = property - && property.key.static_name().is_none_or(|key| key != "vars") - { - px_value(ast_builder, property); - if matches!(property.value, Expression::ObjectExpression(_)) { - pending.push(&mut property.value); - } + if let Expression::ArrayExpression(array) = rules { + pending.extend( + array + .elements + .iter_mut() + .filter_map(|element| element.as_expression_mut()), + ); + } else if let Expression::ObjectExpression(object) = rules { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && property.key.static_name().is_none_or(|key| key != "vars") + { + px_value(ast_builder, property); + if matches!(property.value, Expression::ObjectExpression(_)) { + pending.push(&mut property.value); } } } - Expression::ArrayExpression(array) => { - pending.extend( - array - .elements - .iter_mut() - .filter_map(|element| element.as_expression_mut()), - ); - } - _ => {} } } }