diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 000000000..6aa4c50d9 --- /dev/null +++ b/.changepacks/changepack_log_compose_known_styles.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 000000000..4d05b2838 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_styled_targets.json b/.changepacks/changepack_log_styled_targets.json new file mode 100644 index 000000000..a51d45fe7 --- /dev/null +++ b/.changepacks/changepack_log_styled_targets.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Styled components render what their `as` prop names, defaulting to the tag or component they were defined with, and pass `forwardedAs` on as `as`, as styled-components and Emotion do; `as` used to reach the rendered tag as an attribute. `Component.withComponent(target)` on a styled component the file binds to a const builds a component rendering the same styles and attrs as `target` (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs new file mode 100644 index 000000000..44dae55a6 --- /dev/null +++ b/libs/extractor/src/composition.rs @@ -0,0 +1,360 @@ +//! Styles composed in one place, such as `css(base, cond && danger)`: when the +//! build knows the parts, a later part's declaration replaces an earlier one's +//! for the same property, selector, breakpoint and layer. Atomic classes alone +//! cannot express that, because which of two classes wins depends on their +//! order in the stylesheet, not on the order they were composed in. + +use css::style_selector::StyleSelector; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; + +use crate::{ExtractStyleProp, ExtractStyleValue}; + +/// What a declaration competes on: property, selector, breakpoint and layer +#[derive(Clone, PartialEq, Eq)] +struct CascadeKey { + property: String, + selector: Option, + level: u8, + layer: Option, +} + +impl CascadeKey { + fn of(value: &ExtractStyleValue) -> Self { + let (property, selector, level, layer) = match value { + ExtractStyleValue::Static(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + ExtractStyleValue::Dynamic(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + // A preset: the only other value a style prop holds + _ => ("typography", None, 0, None), + }; + Self { + property: property.to_string(), + selector: selector.cloned(), + level, + layer: layer.map(ToString::to_string), + } + } +} + +/// What a key resolves to +enum Choice<'a> { + Empty, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// What a part does to a key +enum Overlay<'a> { + /// Leaves what came before + Keep, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// The parts composed so far, in source order: a key a later part sets moves +/// to the end, as when merging objects +#[derive(Default)] +pub struct Composition<'a> { + entries: Vec<(CascadeKey, Choice<'a>)>, + /// Styles whose properties the build cannot pair up, kept as they are + unkeyed: Vec>, +} + +impl<'a> Composition<'a> { + /// A part the build knows completely + pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec>) { + let mut overlays: Vec<(CascadeKey, Overlay<'a>)> = Vec::new(); + for prop in props { + self.overlays(ast_builder, prop, &mut overlays); + } + for (key, overlay) in overlays { + self.overlay(ast_builder, key, overlay); + } + } + + /// A part applying only while `test` holds, or `alternate` otherwise + pub fn apply_conditional( + &mut self, + ast_builder: &AstBuilder<'a>, + test: &Expression<'a>, + consequent: Vec>, + alternate: Vec>, + ) { + let prop = ExtractStyleProp::Conditional { + condition: test.clone_in(ast_builder.allocator()), + consequent: Some(Box::new(ExtractStyleProp::StaticArray(consequent))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray(alternate))), + }; + self.apply(ast_builder, vec![prop]); + } + + /// Each key a prop sets, with what it sets it to + fn overlays( + &mut self, + ast_builder: &AstBuilder<'a>, + prop: ExtractStyleProp<'a>, + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + ) { + match prop { + ExtractStyleProp::Static(value) => { + push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value)); + } + ExtractStyleProp::StaticArray(props) => { + for prop in props { + self.overlays(ast_builder, prop, overlays); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } if [&consequent, &alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)) => + { + let mut sides = [Vec::new(), Vec::new()]; + for (side, prop) in sides.iter_mut().zip([consequent, alternate]) { + if let Some(prop) = prop { + self.overlays(ast_builder, *prop, side); + } + } + let [mut consequent, mut alternate] = sides; + let mut keys: Vec = Vec::new(); + for (key, _) in consequent.iter().chain(&alternate) { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + for key in keys { + let consequent = take_overlay(&mut consequent, &key); + let alternate = take_overlay(&mut alternate, &key); + push_overlay( + overlays, + key, + Overlay::Conditional { + test: condition.clone_in(ast_builder.allocator()), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }, + ); + } + } + prop => self.unkeyed.push(prop), + } + } + + fn overlay(&mut self, ast_builder: &AstBuilder<'a>, key: CascadeKey, overlay: Overlay<'a>) { + let previous = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + .map_or(Choice::Empty, |index| self.entries.remove(index).1); + let choice = resolve(ast_builder, overlay, previous); + self.entries.push((key, choice)); + } + + /// The composed styles, for class names and the stylesheet + #[must_use] + pub fn into_props(self) -> Vec> { + self.entries + .into_iter() + .filter_map(|(_, choice)| into_prop(choice)) + .chain(self.unkeyed) + .collect() + } + + /// The composed atoms when no condition chooses between them + #[must_use] + pub fn unconditional(&self) -> Option> { + if !self.unkeyed.is_empty() { + return None; + } + let mut values = Vec::new(); + for (_, choice) in &self.entries { + match choice { + Choice::Atom(value) => values.push(value.clone()), + // A key is empty only under a condition + Choice::Empty | Choice::Conditional { .. } => return None, + } + } + Some(values) + } +} + +/// Styles a part of `css(...)` gives +pub enum KnownStyles<'a> { + /// The styles of a `css()` class the file binds + Known(Vec), + /// A rule object + Rules(Expression<'a>), +} + +/// A side of a condition among the parts of `css(...)` +pub enum KnownSide<'a> { + Styles(Vec>), + /// A class the build does not know the styles of + Class(Expression<'a>), + Empty, +} + +/// A part of `css(...)`, in source order +pub enum KnownPart<'a> { + Styles(Vec>), + Conditional { + test: Expression<'a>, + consequent: Vec>, + alternate: Vec>, + }, + Class(Expression<'a>), +} + +/// `prop`'s styles at `order`, unless one sets its own +pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { + match prop { + ExtractStyleProp::Static(value) => value.set_style_order(order), + ExtractStyleProp::StaticArray(props) => { + for prop in props { + set_prop_order(prop, order); + } + } + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + set_prop_order(side, order); + } + } + ExtractStyleProp::Enum { map, .. } => { + for prop in map.values_mut().flatten() { + set_prop_order(prop, order); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + set_prop_order(prop, order); + } + } + // Class names the code gives, and styles reported as errors + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + } +} + +/// Whether every style `prop` holds has a key, so a condition around it can be +/// applied key by key +fn keyed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(_) => true, + ExtractStyleProp::StaticArray(props) => props.iter().all(keyed), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => [consequent, alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)), + _ => false, + } +} + +/// `overlay` replacing what `overlays` already sets for the key: a part +/// declaring a property twice keeps the later declaration +fn push_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: CascadeKey, + overlay: Overlay<'a>, +) { + overlays.retain(|(existing, _)| *existing != key); + overlays.push((key, overlay)); +} + +fn take_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: &CascadeKey, +) -> Overlay<'a> { + overlays + .iter() + .position(|(existing, _)| existing == key) + .map_or(Overlay::Keep, |index| overlays.remove(index).1) +} + +/// What `overlay` makes of `previous` +fn resolve<'a>( + ast_builder: &AstBuilder<'a>, + overlay: Overlay<'a>, + previous: Choice<'a>, +) -> Choice<'a> { + match overlay { + Overlay::Keep => previous, + Overlay::Atom(value) => Choice::Atom(value), + Overlay::Conditional { + test, + consequent, + alternate, + } => { + let copy = copy_choice(ast_builder, &previous); + Choice::Conditional { + test, + consequent: Box::new(resolve(ast_builder, *consequent, previous)), + alternate: Box::new(resolve(ast_builder, *alternate, copy)), + } + } + } +} + +fn copy_choice<'a>(ast_builder: &AstBuilder<'a>, choice: &Choice<'a>) -> Choice<'a> { + match choice { + Choice::Empty => Choice::Empty, + Choice::Atom(value) => Choice::Atom(value.clone()), + Choice::Conditional { + test, + consequent, + alternate, + } => Choice::Conditional { + test: test.clone_in(ast_builder.allocator()), + consequent: Box::new(copy_choice(ast_builder, consequent)), + alternate: Box::new(copy_choice(ast_builder, alternate)), + }, + } +} + +fn into_prop(choice: Choice<'_>) -> Option> { + match choice { + Choice::Empty => None, + Choice::Atom(value) => Some(ExtractStyleProp::Static(value)), + Choice::Conditional { + test, + consequent, + alternate, + } => { + let (consequent, alternate) = (into_prop(*consequent), into_prop(*alternate)); + (consequent.is_some() || alternate.is_some()).then(|| ExtractStyleProp::Conditional { + condition: test, + consequent: consequent.map(Box::new), + alternate: alternate.map(Box::new), + }) + } + } +} diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..74cf36ea2 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -137,6 +137,10 @@ impl ExtractDynamicStyle { self.identifier.as_str() } + pub(crate) const fn identifier_mut(&mut self) -> &mut String { + &mut self.identifier + } + pub const fn style_order(&self) -> Option { self.style_order } diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index c8ad9d20d..4033caddd 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -10,7 +10,7 @@ use crate::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, styled_record::prepare, - styled_record_evaluation::bind_render, + styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, @@ -30,6 +30,7 @@ use oxc_ast::{ }, builder::AstBuilder, }; +use oxc_ast_visit::VisitMut; use oxc_span::{GetSpan, SPAN}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; @@ -50,7 +51,323 @@ struct Base<'a> { bound: Option>, } +/// A styled component the file defines, which a component extending it +/// renders in its place: what it renders, its classes and styles, and its +/// attrs, as the extension applies them before its own +pub struct StyledDefinition<'a> { + name: String, + target: Option>, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, + bindings: Vec<(String, Expression<'a>)>, +} + +struct Rename<'b, 'a> { + builder: &'b AstBuilder<'a>, + names: FxHashMap, +} + +impl<'a> VisitMut<'a> for Rename<'_, 'a> { + fn visit_identifier_reference( + &mut self, + identifier: &mut oxc_ast::ast::IdentifierReference<'a>, + ) { + let mut code = String::new(); + // Dynamic identifiers can hold emitted JS code. Captured names + // were excluded from authored source, so only whole tokens change. + let separator = |ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '$'; + for chunk in identifier.name.split_inclusive(separator) { + let token = chunk.trim_end_matches(separator); + code.push_str(self.names.get(token).map_or(token, String::as_str)); + code.push_str(&chunk[token.len()..]); + } + identifier.name = oxc_ast::ast::Ident::from_in(code.as_str(), self.builder.allocator()); + } +} + +impl<'a> StyledDefinition<'a> { + fn visit_expressions( + &mut self, + builder: &AstBuilder<'a>, + mut visit: impl FnMut(&mut Expression<'a>), + ) { + for expression in self + .target + .iter_mut() + .chain(&mut self.bound) + .chain(&mut self.classes) + .chain(&mut self.attrs) + { + visit(expression); + } + for (_, value) in &mut self.bindings { + visit(value); + } + for style in &mut self.styles { + visit_style_expressions(builder, style, &mut visit); + } + } + + fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self { + let allocator = builder.allocator(); + let mut inherited = Self { + name: self.name.clone(), + target: self.target.as_ref().map(|value| value.clone_in(allocator)), + bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), + classes: clone_all(builder, &self.classes).collect(), + styles: self + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: clone_all(builder, &self.attrs).collect(), + bindings: self + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + }; + let mut reserved = vec![scope.clone_in(allocator)]; + inherited.visit_expressions(builder, |value| reserved.push(value.clone_in(allocator))); + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + let reservation = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + let mut reads = Evaluations::new(builder, readable_code(&reservation)); + let mut rename = Rename { + builder, + names: FxHashMap::default(), + }; + for (name, _) in &mut inherited.bindings { + let fresh = reads.fresh_name(); + rename.names.insert(name.clone(), fresh.clone()); + *name = fresh; + } + for (_, value) in &mut inherited.bindings { + rename.visit_expression(value); + } + for style in &mut inherited.styles { + visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value)); + } + let mut reserved = vec![reservation]; + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + *scope = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + inherited + } +} + +impl StyledDefinition<'_> { + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +/// `Component.withComponent(target)`: the styles and attrs of `definition` +/// rendering `target`, a tag name or a component JSX can name; `None` for any +/// other target +pub fn with_component<'a>( + ast_builder: &AstBuilder<'a>, + definition: &StyledDefinition<'a>, + target: &Expression<'a>, + split_filename: Option<&str>, + helper: &mut Option, +) -> Option<(Expression<'a>, StyledDefinition<'a>)> { + let name = match unwrap_syntax_only(target) { + Expression::StringLiteral(literal) => literal.value.to_string(), + target => jsx_name(target)?, + }; + let allocator = ast_builder.allocator(); + let base = Base { + target: match unwrap_syntax_only(target) { + Expression::StringLiteral(_) => None, + _ => Some(target.clone_in(allocator)), + }, + ..Base::named(name) + }; + let mut scope = target.clone_in(allocator); + let definition = definition.rebind(ast_builder, &mut scope); + let mut styles: Vec> = definition + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(); + let classes: Vec> = clone_all(ast_builder, &definition.classes).collect(); + let class_name = merge_expression_for_class_name( + ast_builder, + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), + ); + let component = create_styled_component( + ast_builder, + &base, + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &styles, None), + bindings: definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + }, + helper, + ); + let new_definition = base.definition( + ast_builder, + classes, + &styles, + &definition.attrs, + &definition.bindings, + ); + Some(( + apply_attrs(ast_builder, component, &definition.attrs), + new_definition, + )) +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + pub expression: Expression<'a>, + pub errors: Vec<(u32, String)>, + pub definition: Option>, +} + +/// The styles a component renders: those it extends or its base component +/// brings, then its own, a later declaration replacing an earlier one +fn compose_styles<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + defaults: Option>, + own: Vec>, +) -> Vec> { + let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { + inherited + .styles + .iter() + .map(|style| style.clone_in(ast_builder.allocator())) + .collect() + }); + earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static)); + if earlier.is_empty() { + return own; + } + let mut composition = crate::composition::Composition::default(); + composition.apply(ast_builder, earlier); + composition.apply(ast_builder, own); + composition.into_props() +} + +/// The classes and attrs a component extending `inherited` starts from +fn inherited_parts<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + attrs: &[Expression<'a>], +) -> (Vec>, Vec>) { + let allocator = ast_builder.allocator(); + let (classes, mut all_attrs) = inherited.map_or_else( + || (Vec::new(), Vec::new()), + |inherited| { + ( + inherited + .classes + .iter() + .map(|c| c.clone_in(allocator)) + .collect(), + inherited + .attrs + .iter() + .map(|a| a.clone_in(allocator)) + .collect(), + ) + }, + ); + all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + (classes, all_attrs) +} + impl<'a> Base<'a> { + /// What `inherited` renders, in place of the component defining it + fn extending( + self, + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + ) -> Self { + match inherited { + Some(inherited) => Self { + name: inherited.name.clone(), + target: inherited + .target + .as_ref() + .map(|target| target.clone_in(ast_builder.allocator())), + styles: None, + bound: inherited + .bound + .as_ref() + .map(|bound| bound.clone_in(ast_builder.allocator())), + }, + None => self, + } + } + + /// The definition of the component rendering this base + fn definition( + &self, + ast_builder: &AstBuilder<'a>, + classes: Vec>, + styles: &[ExtractStyleProp<'a>], + attrs: &[Expression<'a>], + bindings: &[(String, Expression<'a>)], + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + target: self + .target + .as_ref() + .map(|target| target.clone_in(allocator)), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes, + styles: styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + bindings: bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + } + } + const fn named(name: String) -> Self { Self { name, @@ -70,6 +387,29 @@ impl<'a> Base<'a> { } } +/// The component `styled(Component)` extends, as written: the argument naming +/// it in a tagged template `styled(Component)`, `styled(Component)({...})` or +/// `styled(Component, {...})` +#[must_use] +pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + let factory = match expression { + Expression::TaggedTemplateExpression(tag) => &tag.tag, + Expression::CallExpression(call) + if matches!( + unwrap_syntax_only(&call.callee), + Expression::CallExpression(_) + ) => + { + &call.callee + } + expression => expression, + }; + let Expression::CallExpression(call) = unwrap_syntax_only(factory) else { + return None; + }; + unwrap_syntax_only(call.arguments.first()?.as_expression()?).into() +} + fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, @@ -192,9 +532,12 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, attrs: &[Expression<'a>], + inherited: Option<&StyledDefinition<'a>>, helper: &mut Option, -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { - let original_scope = expression.clone_in(ast_builder.allocator()); +) -> StyledExtraction<'a> { + let mut original_scope = expression.clone_in(ast_builder.allocator()); + let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope)); + let inherited = inherited.as_ref(); let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -220,7 +563,8 @@ pub fn extract_style_from_styled<'a>( None => {} } } - let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression + let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) = + expression && let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports) { // Case 1: styled.div`css` or styled("div")`css` @@ -240,11 +584,12 @@ pub fn extract_style_from_styled<'a>( ) })); if !errors.is_empty() { - return ( - ExtractResult::default(), - Expression::new_string_literal(SPAN, "", None, ast_builder), + return StyledExtraction { + result: ExtractResult::default(), + expression: Expression::new_string_literal(SPAN, "", None, ast_builder), errors, - ); + definition: None, + }; } let TemplateStyles { @@ -263,16 +608,16 @@ pub fn extract_style_from_styled<'a>( .filter(|expression| crate::css_utils::reads_unmapped_theme(expression)) .map(|expression| crate::css_utils::unmapped_theme_error("styled", expression)), ); - let mut props_styles: Vec> = styles + let own: Vec> = styles .into_iter() .map(|ex| ExtractStyleProp::Static(ex.into())) .collect(); + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut props_styles = compose_styles(ast_builder, inherited, defaults, own); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); - if let Some(default_class_name) = base.styles.take() { - props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); - } - - let mixins = statements.into_iter().map(|index| { + classes.extend(statements.into_iter().map(|index| { let mixin = &tag.quasi.expressions[index]; if matches!( unwrap_syntax_only(mixin), @@ -293,18 +638,15 @@ pub fn extract_style_from_styled<'a>( } else { mixin.clone_in(ast_builder.allocator()) } - }); + })); let class_name = merge_expression_for_class_name( ast_builder, - mixins - .collect::>() - .into_iter() - .chain(gen_class_names( - ast_builder, - &mut props_styles, - None, - split_filename, - )), + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut props_styles, + None, + split_filename, + )), ); let tag = Some(Expression::new_string_literal( SPAN, @@ -312,17 +654,26 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); + let bindings = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + let definition = base.definition(ast_builder, classes, &props_styles, &attrs, &bindings); let component = create_styled_component( ast_builder, &base, StyledRender { class_name: &class_name, style_vars: &gen_styles(ast_builder, &props_styles, None), - bindings: Vec::new(), + bindings, }, helper, ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -332,7 +683,7 @@ pub fn extract_style_from_styled<'a>( props: None, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else if let Expression::CallExpression(call) = expression && let Some((mut base, style_index)) = resolve_styled_call_target(ast_builder, call, imports) @@ -343,13 +694,20 @@ pub fn extract_style_from_styled<'a>( let prepared = call.arguments[style_index] .as_expression() .and_then(|rules| prepare(ast_builder, rules, &original_scope)); - let bindings = match prepared { + let mut bindings: Vec<_> = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + bindings.extend(match prepared { Some(prepared) => { call.arguments[style_index] = Argument::from(prepared.rules); prepared.bindings } None => Vec::new(), - }; + }); // Extract styles from object expression let ExtractResult { mut styles, @@ -377,19 +735,27 @@ pub fn extract_style_from_styled<'a>( build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), ) })); - if let Some(default_class_name) = base.styles.take() { - styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + if let Some(order) = style_order { + for style in &mut styles { + crate::composition::set_prop_order(style, order); + } } + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut styles = compose_styles(ast_builder, inherited, defaults, styles); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); + classes.extend(composed_classes); let class_name = merge_expression_for_class_name( ast_builder, - composed_classes.into_iter().chain(gen_class_names( + clone_all(ast_builder, &classes).chain(gen_class_names( ast_builder, &mut styles, - style_order, + None, split_filename, )), ); + let definition = base.definition(ast_builder, classes, &styles, &attrs, &bindings); let component = create_styled_component( ast_builder, &base, @@ -400,7 +766,8 @@ pub fn extract_style_from_styled<'a>( }, helper, ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -410,7 +777,7 @@ pub fn extract_style_from_styled<'a>( props, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else { // Left as written it would call `styled` at runtime, which only the build runs let code = match &*expression { @@ -421,13 +788,23 @@ pub fn extract_style_from_styled<'a>( expression.span().start, build_time_error("styled", &code, STYLED_FACTORY), )); - (None, None) + (None, None, None) }; - ( - result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + StyledExtraction { + result: result.unwrap_or_else(ExtractResult::default), + expression: new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), errors, - ) + definition, + } +} + +fn clone_all<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + expressions: &'s [Expression<'a>], +) -> impl Iterator> + 's { + expressions + .iter() + .map(|expression| expression.clone_in(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -509,44 +886,215 @@ fn apply_attrs<'a>( if attrs.is_empty() { return component; } - let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); - let mut merged = props(); + let merged = if attrs.iter().all(sets_plain_props) { + spread_attrs(ast_builder, attrs) + } else { + merge_attrs(ast_builder, attrs) + }; + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +/// The binding each attrs step reads the props merged so far through +const ATTRS_CONTEXT: &str = "__devupContext"; +/// The binding each attrs step reads what its attrs give through +const ATTRS_VALUE: &str = "__devupAttrs"; + +/// Whether `attr` is an object setting props other than `className` and +/// `style` by name, which spreading over the props merges as attrs do +fn sets_plain_props(attr: &Expression<'_>) -> bool { + let Expression::ObjectExpression(object) = unwrap_syntax_only(attr) else { + return false; + }; + object.properties.iter().all(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && property + .key + .static_name() + .is_some_and(|name| name != "className" && name != "style")) + }) +} + +/// The props with each attrs spread over them in order +fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = identifier(ast_builder, ATTRS_PROPS); for attr in attrs { - let attr = attr.clone_in(ast_builder.allocator()); - if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { - merged = spread_objects(ast_builder, merged, attr); - continue; - } - let called = wrap_direct_call(ast_builder, &attr, &[props()]); - let resolved = if matches!( - unwrap_syntax_only(&attr), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - called - } else { - let is_function = Expression::new_binary_expression( + merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + } + merged +} + +/// The props merged as styled-components merges attrs: each attrs, or what +/// calling it with the props merged so far gives, joins its `className` to +/// theirs, merges its `style` over theirs and replaces their other props; the +/// caller's `className` comes last +fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = with_property( + ast_builder, + identifier(ast_builder, ATTRS_PROPS), + "className", + identifier(ast_builder, "undefined"), + ); + for attr in attrs { + let resolved = spread_objects( + ast_builder, + Expression::new_object_expression( SPAN, - Expression::new_unary_expression( - SPAN, - UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), - ast_builder, - ), - BinaryOperator::StrictEquality, - Expression::new_string_literal(SPAN, "function", None, ast_builder), + oxc_allocator::Vec::new_in(ast_builder), ast_builder, - ); - Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) - }; - let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + ), + resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + ); + let merge = call_with_values( + ast_builder, + vec![(ATTRS_VALUE.to_string(), resolved)], + merge_attrs_value(ast_builder), + ); + let step = named_arrow(ast_builder, ATTRS_CONTEXT, merge); merged = wrap_direct_call(ast_builder, &step, &[merged]); } - props_arrow( + let caller_class = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_PROPS, "className"), + ); + let last = named_arrow( + ast_builder, + ATTRS_CONTEXT, + with_property( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + "className", + caller_class, + ), + ); + wrap_direct_call(ast_builder, &last, &[merged]) +} + +/// What `attr` gives: itself when it is an object, or what calling it with +/// the props merged so far gives when it is a function +fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expression<'a> { + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + return attr; + } + let called = wrap_direct_call( + ast_builder, + &attr, + &[identifier(ast_builder, ATTRS_CONTEXT)], + ); + if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + return called; + } + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) +} + +/// `{ ...context, ...attrs, className, style }` with the classes joined and +/// the styles merged +fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> { + let merged = spread_objects( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + identifier(ast_builder, ATTRS_VALUE), + ); + let class_name = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_VALUE, "className"), + ); + let style = spread_objects( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "style"), + member(ast_builder, ATTRS_VALUE, "style"), + ); + let merged = with_property(ast_builder, merged, "className", class_name); + with_property(ast_builder, merged, "style", style) +} + +fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> { + Expression::new_identifier(SPAN, name, ast_builder) +} + +fn member<'a>( + ast_builder: &AstBuilder<'a>, + object: &'static str, + name: &'static str, +) -> Expression<'a> { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + identifier(ast_builder, object), + oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder), + false, + ast_builder, + )) +} + +/// `[first, second].filter(Boolean).join(" ") || undefined` +fn joined_class<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let joined = wrap_array_filter(ast_builder, &[first, second]) + .unwrap_or_else(|| identifier(ast_builder, "undefined")); + Expression::new_logical_expression( + SPAN, + joined, + LogicalOperator::Or, + identifier(ast_builder, "undefined"), ast_builder, - wrap_direct_call(ast_builder, &component, &[merged]), ) } +/// `object` with `name` set to `value`: an object spreading it, then setting +/// the property +fn with_property<'a>( + ast_builder: &AstBuilder<'a>, + object: Expression<'a>, + name: &'static str, + value: Expression<'a>, +) -> Expression<'a> { + let mut properties = match object { + Expression::ObjectExpression(object) => object.unbox().properties, + object => oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_spread_property( + SPAN, + object, + ast_builder, + )], + ast_builder, + ), + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, name, ast_builder), + value, + false, + false, + false, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + fn spread_objects<'a>( ast_builder: &AstBuilder<'a>, first: Expression<'a>, @@ -567,10 +1115,19 @@ fn spread_objects<'a>( } fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + named_arrow(ast_builder, ATTRS_PROPS, body) +} + +/// `(name) => body` +fn named_arrow<'a>( + ast_builder: &AstBuilder<'a>, + name: &'static str, + body: Expression<'a>, +) -> Expression<'a> { let parameter = FormalParameter::new( SPAN, oxc_allocator::Vec::new_in(ast_builder), - BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), None::>>, None::>>, false, @@ -597,6 +1154,10 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres ) } +/// The binding a styled component renders through: what `as` names, or the +/// tag or component it was defined with +const RENDERED: &str = "DevupAs"; + struct StyledRender<'b, 'a> { class_name: &'b Option>, style_vars: &'b Option>, @@ -645,6 +1206,49 @@ fn create_styled_component<'a>( false, ast_builder, ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier(SPAN, "as", ast_builder), + BindingPattern::new_assignment_pattern( + SPAN, + BindingPattern::new_binding_identifier( + SPAN, + RENDERED, + ast_builder, + ), + base.target.as_ref().map_or_else( + || { + Expression::new_string_literal( + SPAN, + Str::from_in(&base.name, ast_builder.allocator()), + None, + ast_builder, + ) + }, + |target| target.clone_in(ast_builder.allocator()), + ), + ast_builder, + ), + false, + false, + ast_builder, + ), + BindingProperty::new( + SPAN, + PropertyKey::new_static_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + BindingPattern::new_binding_identifier( + SPAN, + "forwardedAs", + ast_builder, + ), + true, + false, + ast_builder, + ), ], ast_builder, ), @@ -695,28 +1299,17 @@ fn create_styled_component<'a>( }, ); let body = if let Some(helper) = helper { - let target = base.target.as_ref().map_or_else( - || { - Expression::new_string_literal( - SPAN, - Str::from_in(&base.name, ast_builder.allocator()), - None, - ast_builder, - ) - }, - |target| target.clone_in(ast_builder.allocator()), - ); - helper.render(ast_builder, target, (class_name, style)) + helper.render( + ast_builder, + identifier(ast_builder, RENDERED), + (class_name, style, identifier(ast_builder, "forwardedAs")), + ) } else { Expression::new_jsx_element( SPAN, JSXOpeningElement::boxed( SPAN, - JSXElementName::new_identifier( - SPAN, - Str::from_in(&base.name, ast_builder.allocator()), - ast_builder, - ), + JSXElementName::new_identifier(SPAN, RENDERED, ast_builder), None::>>, oxc_allocator::Vec::from_iter_in( vec![ @@ -725,6 +1318,16 @@ fn create_styled_component<'a>( Expression::new_identifier(SPAN, "rest", ast_builder), ast_builder, ), + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, "as", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + identifier(ast_builder, "forwardedAs").into(), + ast_builder, + )), + ast_builder, + ), JSXAttributeItem::new_attribute( SPAN, JSXAttributeName::new_identifier(SPAN, "className", ast_builder), diff --git a/libs/extractor/src/extractor/styled_jsx.rs b/libs/extractor/src/extractor/styled_jsx.rs index 75af546ef..f820ca26a 100644 --- a/libs/extractor/src/extractor/styled_jsx.rs +++ b/libs/extractor/src/extractor/styled_jsx.rs @@ -59,16 +59,16 @@ impl StyledJsx { &mut self, ast: &AstBuilder<'a>, target: Expression<'a>, - props: (Expression<'a>, Expression<'a>), + props: (Expression<'a>, Expression<'a>, Expression<'a>), ) -> Expression<'a> { self.used = true; - let mut properties = Vec::with_capacity_in(3, ast); + let mut properties = Vec::with_capacity_in(4, ast); properties.push(ObjectPropertyKind::new_spread_property( SPAN, Expression::new_identifier(SPAN, "rest", ast), ast, )); - for (key, value) in [("className", props.0), ("style", props.1)] { + for (key, value) in [("className", props.0), ("style", props.1), ("as", props.2)] { properties.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/libs/extractor/src/extractor/styled_jsx_tests.rs b/libs/extractor/src/extractor/styled_jsx_tests.rs index 024c57303..13c9b8737 100644 --- a/libs/extractor/src/extractor/styled_jsx_tests.rs +++ b/libs/extractor/src/extractor/styled_jsx_tests.rs @@ -22,6 +22,7 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() { ( Expression::new_identifier(SPAN, "className", &ast), Expression::new_identifier(SPAN, "style", &ast), + Expression::new_string_literal(SPAN, "nav", None, &ast), ), ); // When: the real binding and renderer execute against a CommonJS factory export. @@ -46,7 +47,7 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() { .as_string() .unwrap_or_else(|| panic!("CommonJS renderer JSON result must be a string")) .to_std_string_escaped(), - r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"}}}"#, + r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"},"as":"nav"}}"#, ); } diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs index 6a2ac15c8..662a220ab 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -23,13 +23,7 @@ impl<'b, 'a> Evaluations<'b, 'a> { } pub fn capture(&mut self, value: &Expression<'a>, guard: &Expression<'a>) -> Expression<'a> { - let name = loop { - let name = format!("__devupRecord{}", self.next); - self.next += 1; - if !self.source.contains(&name) { - break name; - } - }; + let name = self.fresh_name(); let value = self.choose(guard, value, &self.undefined()); self.bindings.push((name.clone(), value)); Expression::new_identifier( @@ -39,6 +33,16 @@ impl<'b, 'a> Evaluations<'b, 'a> { ) } + pub fn fresh_name(&mut self) -> String { + loop { + let name = format!("__devupRecord{}", self.next); + self.next += 1; + if !self.source.contains(&name) { + return name; + } + } + } + pub fn boolean(&self, value: bool) -> Expression<'a> { Expression::new_boolean_literal(SPAN, value, self.builder) } @@ -73,6 +77,76 @@ impl<'b, 'a> Evaluations<'b, 'a> { } } +fn visit_style_value_expression<'a>( + builder: &AstBuilder<'a>, + style: &mut crate::ExtractStyleValue, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleValue; + match style { + ExtractStyleValue::Dynamic(value) => { + let mut expression = Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(value.identifier(), builder.allocator()), + builder, + ); + visit(&mut expression); + *value.identifier_mut() = crate::utils::readable_code(&expression); + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => {} + } +} + +pub(super) fn visit_style_expressions<'a>( + builder: &AstBuilder<'a>, + style: &mut crate::ExtractStyleProp<'a>, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleProp; + match style { + ExtractStyleProp::Static(value) => visit_style_value_expression(builder, value, visit), + ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::StaticArray(styles) => { + for style in styles { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + visit(condition); + for style in consequent.iter_mut().chain(alternate.iter_mut()) { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Enum { condition, map } => { + visit(condition); + for style in map.values_mut().flatten() { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Expression { expression, styles } => { + visit(expression); + for style in styles { + visit_style_value_expression(builder, style, visit); + } + } + ExtractStyleProp::MemberExpression { expression, map } => { + visit(expression); + for style in map.values_mut() { + visit_style_expressions(builder, style, visit); + } + } + } +} + pub(super) fn bind_render<'a>( builder: &AstBuilder<'a>, bindings: Vec<(String, Expression<'a>)>, @@ -85,6 +159,10 @@ pub(super) fn bind_render<'a>( body } +#[cfg(test)] +#[path = "styled_record_evaluation_visitor_tests.rs"] +mod visitor_tests; + #[cfg(test)] mod tests { use boa_engine::{Context, Source}; diff --git a/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs new file mode 100644 index 000000000..8cc7b8dcf --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; + +use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; +use rstest::rstest; + +use super::visit_style_expressions; +use crate::{ + ExtractStyleProp, ExtractStyleValue, + extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + }, + utils::readable_code, +}; + +#[derive(Clone, Copy)] +enum Container { + Conditional, + StaticArray, + Enum, + Expression, + MemberExpression, +} + +fn identifier<'a>(builder: &AstBuilder<'a>, name: &str) -> Expression<'a> { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name, builder.allocator()), + builder, + ) +} + +fn payload<'a>(builder: &AstBuilder<'a>, name: &str) -> ExtractStyleProp<'a> { + ExtractStyleProp::Expression { + expression: identifier(builder, name), + styles: vec![ + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 2, name, None)), + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "background", + 4, + &format!("{name} + {name}.color !important"), + None, + )), + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )), + ], + } +} + +fn assert_identifier(expression: &Expression<'_>, expected: &str) { + let Expression::Identifier(identifier) = expression else { + panic!("visitor must preserve the identifier expression"); + }; + assert_eq!(identifier.name.as_str(), expected); + assert_eq!(readable_code(expression), expected); +} + +fn assert_payload(style: &ExtractStyleProp<'_>, expected: &str) { + let ExtractStyleProp::Expression { expression, styles } = style else { + panic!("fixture payload must remain an Expression"); + }; + assert_identifier(expression, expected); + assert_eq!(styles.len(), 3); + let ExtractStyleValue::Dynamic(simple) = &styles[0] else { + panic!("first payload must remain dynamic"); + }; + assert_eq!(simple.identifier(), expected); + assert_eq!( + simple, + &ExtractDynamicStyle::new("color", 2, expected, None) + ); + let ExtractStyleValue::Dynamic(code) = &styles[1] else { + panic!("second payload must remain dynamic code"); + }; + assert_eq!(code.identifier(), format!("{expected} + {expected}.color")); + assert_eq!( + code, + &ExtractDynamicStyle::new( + "background", + 4, + &format!("{expected} + {expected}.color !important"), + None, + ) + ); + assert_eq!( + styles[2], + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )) + ); +} + +#[test] +#[should_panic(expected = "visitor must preserve the identifier expression")] +fn identifier_guard_rejects_when_expression_is_null() { + // Given: an AST expression with the wrong shape. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Expression::new_null_literal(SPAN, &builder); + // When/Then: the existing identifier assertion rejects it at its shape guard. + assert_identifier(&expression, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "fixture payload must remain an Expression")] +fn payload_guard_rejects_when_container_is_an_array() { + // Given: a non-Expression container. + let style = ExtractStyleProp::StaticArray(vec![]); + // When/Then: the existing payload assertion rejects the container shape. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "first payload must remain dynamic")] +fn payload_guard_rejects_when_first_declaration_is_static() { + // Given: a valid payload with only its first declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[0] = styles[2].clone(); + } + // When/Then: earlier identifier/length checks pass, then the first guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "second payload must remain dynamic code")] +fn payload_guard_rejects_when_second_declaration_is_static() { + // Given: a valid payload with only its second declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[1] = styles[2].clone(); + } + // When/Then: the first declaration checks pass, then the second guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[rstest] +#[case::conditional(Container::Conditional)] +#[case::static_array(Container::StaticArray)] +#[case::enum_map(Container::Enum)] +#[case::expression(Container::Expression)] +#[case::member_map(Container::MemberExpression)] +fn payloads_are_rewritten_when_style_expressions_are_visited(#[case] container: Container) { + // Given: two distinct nested captures, including serialized dynamic code. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let first = payload(&builder, "__devupRecord0"); + let second = payload(&builder, "__devupRecord1"); + let control = identifier(&builder, "__devupRecord2"); + let mut style = match container { + Container::Conditional => ExtractStyleProp::Conditional { + condition: control, + consequent: Some(Box::new(first)), + alternate: Some(Box::new(second)), + }, + Container::StaticArray => ExtractStyleProp::StaticArray(vec![first, second]), + Container::Enum => ExtractStyleProp::Enum { + condition: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), vec![first, second]), + ("empty".to_string(), vec![]), + ]), + }, + Container::Expression => first, + Container::MemberExpression => ExtractStyleProp::MemberExpression { + expression: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), Box::new(first)), + ("second".to_string(), Box::new(second)), + ]), + }, + }; + + // When: the callback rewrites actual AST identifier references/code payloads. + visit_style_expressions(&builder, &mut style, &mut |expression| { + let Expression::Identifier(value) = expression else { + panic!("fixture visitor receives identifier expressions"); + }; + let renamed = value.name.replace("__devupRecord", "__devupInherited"); + value.name = oxc_ast::ast::Ident::from_in(renamed.as_str(), builder.allocator()); + }); + + // Then: every branch/map/array entry and AST control was rewritten. + match &style { + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + assert_identifier(condition, "__devupInherited2"); + let (Some(consequent), Some(alternate)) = (consequent, alternate) else { + panic!("both conditional branches must remain present"); + }; + assert_payload(consequent, "__devupInherited0"); + assert_payload(alternate, "__devupInherited1"); + } + ExtractStyleProp::StaticArray(styles) => { + assert_eq!(styles.len(), 2); + assert_payload(&styles[0], "__devupInherited0"); + assert_payload(&styles[1], "__devupInherited1"); + } + ExtractStyleProp::Enum { condition, map } => { + assert_identifier(condition, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_eq!(map["empty"].len(), 0); + assert_payload(&map["__devupRecord0"][0], "__devupInherited0"); + assert_payload(&map["__devupRecord0"][1], "__devupInherited1"); + } + ExtractStyleProp::Expression { .. } => assert_payload(&style, "__devupInherited0"), + ExtractStyleProp::MemberExpression { expression, map } => { + assert_identifier(expression, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_payload(&map["__devupRecord0"], "__devupInherited0"); + assert_payload(&map["second"], "__devupInherited1"); + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => { + panic!("container must remain unchanged"); + } + } +} + +#[test] +fn metadata_is_unchanged_when_style_expressions_are_visited() { + // Given: static declarations and unreadable diagnostics are not expressions. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let static_value = ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )); + let mut style = ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(static_value.clone()), + ExtractStyleProp::Unreadable { + offset: 17, + code: "__devupRecord0".to_string(), + prop: true, + requirement: Some("a valid selector"), + }, + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupRecord0", + None, + ))), + ]); + + // When: only the dynamic declaration is visited. + visit_style_expressions(&builder, &mut style, &mut |expression| { + assert_identifier(expression, "__devupRecord0"); + *expression = identifier(&builder, "__devupInherited0"); + }); + + // Then: metadata stays byte-identical and the dynamic declaration changes. + let ExtractStyleProp::StaticArray(styles) = style else { + panic!("array fixture"); + }; + let ExtractStyleProp::Static(value) = &styles[0] else { + panic!("static fixture"); + }; + assert_eq!(value, &static_value); + let ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } = &styles[1] + else { + panic!("unreadable fixture"); + }; + assert_eq!( + (*offset, code.as_str(), *prop), + (17, "__devupRecord0", true) + ); + assert_eq!(*requirement, Some("a valid selector")); + let ExtractStyleProp::Static(value) = &styles[2] else { + panic!("dynamic fixture"); + }; + assert_eq!( + value, + &ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupInherited0", + None, + )) + ); +} diff --git a/libs/extractor/src/extractor/styled_record_tests.rs b/libs/extractor/src/extractor/styled_record_tests.rs index 4f999f15d..0174703be 100644 --- a/libs/extractor/src/extractor/styled_record_tests.rs +++ b/libs/extractor/src/extractor/styled_record_tests.rs @@ -9,6 +9,206 @@ use rstest::rstest; use super::{styled_record::prepare, styled_record_evaluation::bind_render}; use crate::utils::readable_code; +#[rstest] +#[case::prepared_base("const Ext=styled(Base)({'p':1});", "", "4px")] +#[case::two_levels( + "const Mid=styled(Base)({'p':1});const Ext=styled(Mid)({'p':2});", + "", + "8px" +)] +#[case::tagged_extension("const Ext=styled(Base)`padding:4px`;", "", "4px")] +#[case::retargeted_extension( + "const Mid=Base.withComponent('section');const Ext=styled(Mid)({'p':1});", + "", + "4px" +)] +#[case::capture_collisions( + "const Mid=styled(Base)({backgroundColor:'white',...((trace.push('own'),own)?{backgroundColor:'black'}:{})});const Ext=styled(Mid)(__devupRecord0,{p:1,...((trace.push('authored'),__devupRecord1)?{p:2}:{})});", + ",own,__devupRecord0,__devupRecord1", + "8px" +)] +#[serial_test::serial] +fn inherited_captures_when_native_extension_renders( + #[case] extension: &str, + #[case] parameters: &str, + #[case] padding: &str, + #[values(false, true)] on: bool, + #[values(false, true)] authored: bool, + #[values(false, true)] single_css: bool, +) { + // Given: independent inherited, own and authored controllers. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on{parameters}){{const Base=styled.div({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});{extension}return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},{},'authored',{authored});", + !on + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("extension fixture must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted native render, not a generated-name assertion. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='authored'||name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: only the selected declarations survive, with ordered single reads. + let color = if on { "blue" } else { "red" }; + let target = if extension.contains("withComponent") { + "section" + } else { + "div" + }; + let expected = if parameters.is_empty() { + format!( + r#"["{target}","extension",[["color","{color}"],["padding","{padding}"]],["caller"],["base"]]"# + ) + } else { + let background = if on { "white" } else { "black" }; + let padding = if authored { padding } else { "4px" }; + format!( + r#"["div","extension",[["background-color","{background}"],["color","{color}"],["padding","{padding}"]],["authored","caller"],["base","own","authored"]]"# + ) + }; + assert_eq!(rendered, expected); +} + +#[rstest] +#[serial_test::serial] +fn inherited_captures_when_dynamic_native_base_renders( + #[values(false, true)] on: bool, + #[values(false, true)] single_css: bool, +) { + // Given: a lowercase runtime target binding and an inherited captured guard. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on,target){{const Base=styled(target)({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Ext=styled(Base)({{p:1}});return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},'div');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dynamic base extension must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted extension with its actual runtime tag value. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: the tag, selected color, extension padding and single read survive. + let color = if on { "blue" } else { "red" }; + assert_eq!( + rendered, + format!( + r#"["div","extension",[["color","{color}"],["padding","4px"]],["caller"],["base"]]"# + ) + ); +} + +#[rstest] +#[serial_test::serial] +fn inherited_dependent_reads_when_attrs_and_later_styles_compose( + #[values(false, true)] outer: bool, + #[values(false, true)] inner: bool, + #[values(false, true)] single_css: bool, +) { + // Given: nested captured guards, a dynamic leaf and an authored attrs name. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(outer,inner,__devupRecord0){{const Base=styled.div.attrs({{title:__devupRecord0,id:'base'}})({{color:'red',backgroundColor:'white',...((trace.push('outer'),outer)?{{...((trace.push('inner'),inner)?{{backgroundColor:(trace.push('leaf'),'gold')}}:{{}})}}:{{}})}});const Ext=styled(Base).attrs({{id:'extension'}})({{color:'orange'}});return Ext({{}});}}const rendered=make({outer},{inner},'authored-title');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dependent extension must extract: {error}"), + }; + let mut colors = Vec::new(); + for style in &output.styles { + if let crate::ExtractStyleValue::Static(value) = style + && value.property() == "color" + && let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + { + colors.push(format!("['{name}','{}']", value.value())); + } + } + // When: render the flattened extension through the existing native harness. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');JSON.stringify([rendered.props.id,rendered.props.title,[{}].filter(([name])=>names.includes(name)).map(([,value])=>value),Object.values(rendered.props.style||{{}}).filter(value=>value!=null),trace]);", + colors.join(",") + ), + ); + // Then: guarded reads stay single and ordered, attrs and later color win. + let expected = match (outer, inner) { + (true, true) => { + r#"["extension","authored-title",["orange"],["gold"],["outer","inner","leaf"]]"# + } + (true, false) => r#"["extension","authored-title",["orange"],[],["outer","inner"]]"#, + (false, _) => r#"["extension","authored-title",["orange"],[],["outer"]]"#, + }; + assert_eq!(rendered, expected); +} + fn evaluate_record(rules: &str, setup: &str) -> String { let allocator = Allocator::default(); let builder = AstBuilder::new(&allocator); @@ -287,7 +487,7 @@ fn original_record_when_unaffected_or_unreadable(#[case] rules: &str) { assert_eq!(readable_code(&statement.expression), original); } -/// Evaluate the emitted render scope, lowering only its empty native JSX element. +/// Evaluate the emitted render scope, lowering its empty native or bound JSX element. fn evaluate_native_render(code: &str, observation: &str) -> String { use oxc_ast::ast::{ ImportDeclaration, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, @@ -306,10 +506,17 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { assert_eq!(element.children.len(), 0); - let JSXElementName::Identifier(tag) = &element.opening_element.name else { - panic!("fixture must render a native element"); + let tag = match &element.opening_element.name { + JSXElementName::Identifier(tag) => { + assert_eq!(tag.name, "div"); + "'div'" + } + JSXElementName::IdentifierReference(tag) => { + assert!(["DevupStyled", "DevupAs"].contains(&tag.name.as_str())); + tag.name.as_str() + } + _ => panic!("fixture must render a native element"), }; - assert_eq!(tag.name, "div"); let props: Vec<_> = element .opening_element .attributes @@ -336,7 +543,7 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { .collect(); self.0.push(( element.span, - format!("({{type:'div',props:{{{}}}}})", props.join(",")), + format!("({{type:{tag},props:{{{}}}}})", props.join(",")), )); } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..82ad48295 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -21,8 +21,13 @@ use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::composition::{Composition, set_prop_order}; +use crate::extractor::ExtractResult; +use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, +}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +49,9 @@ enum Constant { /// The class a `StyleX` theme applies Theme(String), /// What another style API gives: a class, a component or a keyframes - /// name, never rules - Style, + /// name, never rules; for a `css()` class, the styles behind it when + /// they are known + Style(Option>>), /// An object or array code changes, or a value read from one Changed(Rc), } @@ -154,6 +160,8 @@ pub(crate) struct Inlined { pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, + /// The styles behind imported `css()` classes + pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, } @@ -471,6 +479,11 @@ pub(crate) fn inline_constants<'a>( Constant::Theme(class) => { inlined.stylex_themes.insert(name.clone(), class.clone()); } + Constant::Style(Some(styles)) => { + inlined + .css_styles + .insert(name.clone(), styles.as_ref().clone()); + } _ => {} } for symbol in bindings.get(name.as_str()).into_iter().flatten() { @@ -1032,6 +1045,55 @@ impl<'p, 'a> ModuleScope<'p, 'a> { }) } + /// The styles behind `css(rules)`, the package's own `css` given one + /// rule object every value of which is known, as the module's class names + /// do not tell them + fn css_styles( + &mut self, + modules: &mut Modules<'_>, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option>> { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { + return None; + }; + if export != "css" || !source.starts_with(modules.option.package.as_str()) { + return None; + } + let [argument] = call.arguments.as_slice() else { + return None; + }; + let rules = self.evaluate(modules, argument.as_expression()?)?; + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut rules = match constant_literal(&builder, &rules, true)? { + rules @ Expression::ObjectExpression(_) => rules, + _ => return None, + }; + let ExtractResult { + mut styles, + style_order, + .. + } = extract_style_from_expression( + &builder, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + let mut composition = Composition::default(); + composition.apply(&builder, styles); + composition.unconditional().map(Rc::new) + } + fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { self.style_imports.contains(name) || self.style_names.contains(name) @@ -1465,13 +1527,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), + callee if self.is_style_api(modules, callee) => { + Some(Constant::Style(self.css_styles(modules, call))) + } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1837,55 +1901,7 @@ impl<'a> Inline<'_, 'a> { } fn literal(&self, constant: &Constant) -> Option> { - let builder = self.ast_builder; - match constant { - Constant::String(value) => Some(Expression::new_string_literal( - SPAN, - Str::from_in(value.as_str(), builder.allocator()), - None, - builder, - )), - Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, - *value, - None, - NumberBase::Decimal, - builder, - )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), - Constant::Record(entries) if self.objects => { - let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); - for (key, value) in entries.iter() { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - oxc_ast::ast::PropertyKind::Init, - oxc_ast::ast::PropertyKey::StringLiteral( - oxc_ast::ast::StringLiteral::boxed( - SPAN, - Str::from_in(key.as_str(), builder.allocator()), - None, - builder, - ), - ), - self.literal(value)?, - false, - false, - false, - builder, - )); - } - Some(Expression::new_object_expression(SPAN, properties, builder)) - } - Constant::Array(values) if self.objects => { - let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); - for value in values.iter() { - elements.push(self.literal(value)?.into()); - } - Some(Expression::new_array_expression(SPAN, elements, builder)) - } - _ => None, - } + constant_literal(self.ast_builder, constant, self.objects) } fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T { @@ -1904,6 +1920,60 @@ impl<'a> Inline<'_, 'a> { } } +/// `constant` written as a literal, objects and arrays too when `objects` +fn constant_literal<'a>( + builder: &AstBuilder<'a>, + constant: &Constant, + objects: bool, +) -> Option> { + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + )), + constant_literal(builder, value, objects)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(constant_literal(builder, value, objects)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } +} + impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 732ede591..9ff75e804 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -2,6 +2,7 @@ mod as_visit; mod build_time_values; mod class_arguments; mod component; +mod composition; mod css_utils; mod emotion_namespace; pub mod extract_style; @@ -510,6 +511,7 @@ fn extract_source( if global { None } else { Some(bucket) }, ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); + visitor.import_css(inlined.css_styles); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.visit_program(&mut program); @@ -14751,6 +14753,383 @@ const Button = styled.button({ bg: 'red' }) )); } + /// `code` extracted with readable class names + fn readable_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // A later part of `css(...)` replaces an earlier part's declaration of the + // same property, selector, breakpoint and layer, whatever the stylesheet + // order of their classes + #[rstest] + #[case( + "css(yellow, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(azure, yellow)", + r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css({ color: 'green' }, yellow)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: 'green' })", + r#""color-0-red-_a__c_hover-255 color-0-green--255""# + )] + #[case( + "css([yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(...[yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, null, undefined, false, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on && azure)", + r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(on ? yellow : azure)", + r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# + )] + #[case( + "css(yellow, on ? { color: 'pink' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, { color: on ? 'pink' : 'teal' })", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# + )] + #[case( + "css(yellow, ext, azure)", + r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" + )] + #[case( + "css(yellow, on ? 'plain' : azure)", + r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(azure, wide)", + r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# + )] + #[case( + "css(wide, { color: [null, 'x2'] })", + r#""color-0-a1--255 color-1-x2--255""# + )] + #[case( + "css(yellow, ordered)", + r#""color-0-red-_a__c_hover-255 color-0-navy--3""# + )] + #[case("css(heading, body)", r#""typo-body""#)] + #[case( + "css(chained, { color: 'gold' })", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# + )] + #[case( + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" + )] + #[case( + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# + )] + #[case( + "css(yellow, on || azure)", + r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, ext ?? azure)", + r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, null ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(azure || yellow)", + r#""background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on || 'plain')", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# + )] + #[case( + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# + )] + #[case( + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# + )] + #[case( + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, fade)", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${fade}`" + )] + #[case( + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" + )] + #[case( + "css(yellow, { color: 'pink' } || azure)", + r#""color-0-red-_a__c_hover-255 color-0-pink--255""# + )] + #[case( + "css(yellow, undefined ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[serial] + fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + )); + assert!( + code.contains(&format!("export const result = {expected}")), + "{code}" + ); + } + + #[test] + #[serial] + fn test_styled_extension_composes_base() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = ({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => ", + ); + for expected in [ + "export const Aside = (__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => (({ style, className, as: DevupAs = \"nav\",", + "export const Kept = Section.withComponent(make());", + "export const Other = other.withComponent(\"aside\");", + "...{ role: \"region\" }", + "
", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[test] + #[serial] + fn test_styled_attrs_merge_in_order() { + let code = readable_code( + "import {styled} from '@devup-ui/core' +const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' }) +const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' }) +const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' }) +const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' }) +const V = styled.div.attrs(extra)({ color: 'red' })", + ); + for expected in [ + "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }", + "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined", + "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined", + "...__devupContext.style,\n\t\t...__devupAttrs.style", + "((p) => ({ id: p.id }))(__devupContext)", + "typeof extra === \"function\" ? extra(__devupContext) : extra", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + + #[rstest] + #[case( + "", + "className=\"spread\" style={{ opacity: 2 }}" + )] + #[case( + "", + "className=\"direct\" style={{ opacity: 1 }}" + )] + #[case( + "", + "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}" + )] + #[case( + "", + "className=\"last\"" + )] + #[case( + "", + "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}" + )] + #[case( + " color=\"red\" />", + "
" + )] + #[case( + "", + "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}" + )] + #[serial] + fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{Box}} from '@devup-ui/core' +export const a = {element}" + )); + assert!(code.contains(expected), "{expected}\n{code}"); + } + + #[test] + #[serial] + fn test_css_composing_reports_runtime_values() { + let code = readable_code( + "import {css} from '@devup-ui/core' +const base = css({ color: 'red' }) +export const a = css(base, { color: tone }) +export const b = css(base, { [key]: 'x', styleOrder: 2 }) +export const c = css(base, getStyles()) +export const d = css(base, { positioning: side, styleOrder: 2 })", + ); + assert!(!code.contains("test.tsx:6:"), "{code}"); + assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + assert!( + code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), + "{code}" + ); + } + + // The styles of a `css()` class another module exports compose as well + #[test] + #[serial] + fn test_css_composes_imported_classes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/styles.ts", + "import { css, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' }); +export const twice = css({ color: 'a' }, { m: 1 }); +export const text = css`color: blue;`; +export const runtime = css({ color: globalThis.tone }); +export const listed = css([{ color: 'b' }]); +export const spaced = Devup.css({ color: 'c' }); +export const fade = keyframes({ from: { opacity: 0 } }); +export const called = Devup({ color: 'e' }); +export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", + )]; + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; +import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 }); +export const c = css(twice, text, runtime, listed, spaced, fade); +export const d = css(ordered, danger, called);", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap(); + css::debug::set_debug(false); + for expected in [ + r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, + r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, + "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", + ] { + assert!( + output.code.contains(expected), + "{expected} in {}", + output.code + ); + } + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest] diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 4b2a8c588..16f0d7daf 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -62,20 +62,13 @@ fn resolve_class_name_expression<'a>( class_name_prop: &Option>, styles: &mut [ExtractStyleProp<'a>], style_order: Option, - spread_props: &[Expression<'a>], filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, ) -> (Option>, Vec) { if let Some((condition, alt_styles, alt_style_order)) = conditional_branch { // Conditional styleOrder: generate className for both branches - let (con_expr, con_tailwind) = get_class_name_expression( - ast_builder, - class_name_prop, - styles, - style_order, - spread_props, - filename, - ); + let (con_expr, con_tailwind) = + get_class_name_expression(ast_builder, class_name_prop, styles, style_order, filename); let alt_class_name_prop = class_name_prop .as_ref() .map(|c| c.clone_in(ast_builder.allocator())); @@ -84,7 +77,6 @@ fn resolve_class_name_expression<'a>( &alt_class_name_prop, alt_styles, alt_style_order, - spread_props, filename, ); @@ -95,14 +87,7 @@ fn resolve_class_name_expression<'a>( all_tailwind.extend(alt_tailwind); (combined_expr, all_tailwind) } else { - get_class_name_expression( - ast_builder, - class_name_prop, - styles, - style_order, - spread_props, - filename, - ) + get_class_name_expression(ast_builder, class_name_prop, styles, style_order, filename) } } @@ -121,38 +106,44 @@ pub fn modify_prop_object<'a>( filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, ) -> Vec { - let mut class_name_prop = None; - let mut style_prop = None; - let mut spread_props = vec![]; - for idx in (0..props.len()).rev() { - let prop = props.remove(idx); + let mut class_names = Vec::new(); + let mut style_values = Vec::new(); + let written = std::mem::replace(props, oxc_allocator::Vec::new_in(ast_builder)); + for prop in written { match prop { - ObjectPropertyKind::ObjectProperty(attr) => { - if let Some(name) = get_str_by_property_key(&attr.key) { - if name == "className" { - class_name_prop = Some(attr.value.clone_in(ast_builder.allocator())); - } else if name == "style" { - style_prop = Some(attr.value.clone_in(ast_builder.allocator())); - } else { - props.insert(idx, ObjectPropertyKind::ObjectProperty(attr)); - } + ObjectPropertyKind::ObjectProperty(attr) + if matches!( + get_str_by_property_key(&attr.key).as_deref(), + Some("className" | "style") + ) => + { + let value = Written::Prop(attr.value.clone_in(ast_builder.allocator())); + if get_str_by_property_key(&attr.key).as_deref() == Some("className") { + class_names.push(value); } else { - props.insert(idx, ObjectPropertyKind::ObjectProperty(attr)); + style_values.push(value); } } ObjectPropertyKind::SpreadProperty(spread) => { - spread_props.push(spread.argument.clone_in(ast_builder.allocator())); - props.insert(idx, ObjectPropertyKind::SpreadProperty(spread)); + class_names.push(Written::Spread( + spread.argument.clone_in(ast_builder.allocator()), + )); + style_values.push(Written::Spread( + spread.argument.clone_in(ast_builder.allocator()), + )); + props.push(ObjectPropertyKind::SpreadProperty(spread)); } + prop @ ObjectPropertyKind::ObjectProperty(_) => props.push(prop), } } + let class_name_prop = last_written(ast_builder, &class_names, "className"); + let style_prop = last_written(ast_builder, &style_values, "style"); let (class_name_expr, tailwind_styles) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, style_order, - &spread_props, filename, conditional_branch, ); @@ -169,14 +160,8 @@ pub fn modify_prop_object<'a>( ast_builder, )); } - if let Some(ex) = get_style_expression( - ast_builder, - &style_prop, - styles, - &style_vars, - &spread_props, - filename, - ) { + if let Some(ex) = get_style_expression(ast_builder, &style_prop, styles, &style_vars, filename) + { props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, @@ -212,54 +197,54 @@ pub fn modify_props<'a>( filename: Option<&str>, conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>, ) -> Vec { - let mut class_name_prop = None; - let mut style_prop = None; - let mut spread_props = vec![]; - for idx in (0..props.len()).rev() { - let prop = props.remove(idx); + let mut class_names = Vec::new(); + let mut style_values = Vec::new(); + let written = std::mem::replace(props, oxc_allocator::Vec::new_in(ast_builder)); + for prop in written { match prop { - JSXAttributeItem::Attribute(attr) => { - if let Identifier(ident) = &attr.name - && let Some(value) = &attr.value - && (ident.name == "className" || ident.name == "style") - { - let mut res = None; - - if let JSXAttributeValue::ExpressionContainer(container) = &value { - res = container - .expression - .as_expression() - .map(|expression| expression.clone_in(ast_builder.allocator())); - } else if let JSXAttributeValue::StringLiteral(literal) = &value { - res = Some(Expression::new_string_literal( - SPAN, - literal.value, - None, - ast_builder, - )); - } - let name = ident.name.as_str(); - if name == "className" { - class_name_prop = res; - } else if name == "style" { - style_prop = res; - } + JSXAttributeItem::Attribute(attr) + if matches!(&attr.name, Identifier(ident) + if ident.name == "className" || ident.name == "style") + && attr.value.is_some() => + { + let value = match &attr.value { + Some(JSXAttributeValue::ExpressionContainer(container)) => container + .expression + .as_expression() + .map(|expression| expression.clone_in(ast_builder.allocator())), + Some(JSXAttributeValue::StringLiteral(literal)) => Some( + Expression::new_string_literal(SPAN, literal.value, None, ast_builder), + ), + _ => None, + }; + let Some(value) = value else { + continue; + }; + if matches!(&attr.name, Identifier(ident) if ident.name == "className") { + class_names.push(Written::Prop(value)); } else { - props.insert(idx, JSXAttributeItem::Attribute(attr)); + style_values.push(Written::Prop(value)); } } JSXAttributeItem::SpreadAttribute(spread) => { - spread_props.push(spread.argument.clone_in(ast_builder.allocator())); - props.insert(idx, JSXAttributeItem::SpreadAttribute(spread)); + class_names.push(Written::Spread( + spread.argument.clone_in(ast_builder.allocator()), + )); + style_values.push(Written::Spread( + spread.argument.clone_in(ast_builder.allocator()), + )); + props.push(JSXAttributeItem::SpreadAttribute(spread)); } + prop @ JSXAttributeItem::Attribute(_) => props.push(prop), } } + let class_name_prop = last_written(ast_builder, &class_names, "className"); + let style_prop = last_written(ast_builder, &style_values, "style"); let (class_name_expr, tailwind_styles) = resolve_class_name_expression( ast_builder, &class_name_prop, styles, style_order, - &spread_props, filename, conditional_branch, ); @@ -275,14 +260,8 @@ pub fn modify_props<'a>( ast_builder, )); } - if let Some(ex) = get_style_expression( - ast_builder, - &style_prop, - styles, - &style_vars, - &spread_props, - filename, - ) { + if let Some(ex) = get_style_expression(ast_builder, &style_prop, styles, &style_vars, filename) + { props.push(JSXAttributeItem::new_attribute( SPAN, JSXAttributeName::new_identifier(SPAN, "style", ast_builder), @@ -310,7 +289,6 @@ pub fn get_class_name_expression<'a>( class_name_prop: &Option>, styles: &mut [ExtractStyleProp<'a>], style_order: Option, - spread_props: &[Expression<'a>], filename: Option<&str>, ) -> (Option>, Vec) { let mut tailwind = TailwindClassName { @@ -330,32 +308,13 @@ pub fn get_class_name_expression<'a>( .map(|class_name| convert_class_name(ast_builder, class_name)); // Merge class names: [className] + [devup-ui component styles] - let mut class_expressions = Vec::with_capacity(2 + spread_props.len()); + let mut class_expressions = Vec::with_capacity(2); if let Some(class_name) = class_name_to_use { class_expressions.push(class_name); } if let Some(class_name) = gen_class_names(ast_builder, styles, style_order, filename) { class_expressions.push(class_name); } - if class_name_prop.is_none() { - class_expressions.extend( - spread_props - .iter() - .filter(|ex| may_hold(ex, "className")) - .map(|ex| { - convert_class_name( - ast_builder, - &Expression::StaticMemberExpression(StaticMemberExpression::boxed( - SPAN, - ex.clone_in(ast_builder.allocator()), - IdentifierName::new(SPAN, "className", ast_builder), - true, - ast_builder, - )), - ) - }), - ); - } let expression = merge_string_expressions(ast_builder, &class_expressions); (expression, tailwind.into_styles()) @@ -762,6 +721,107 @@ fn template_raw(cooked: &str) -> String { raw } +/// `className` or `style` written as a prop, or spread with other props +pub(crate) enum Written<'a> { + Prop(Expression<'a>), + Spread(Expression<'a>), +} + +/// The value `key` ends up with as the props are written in order: a later +/// prop or spread holding `key` replaces an earlier one. Whether a spread holds +/// it only the runtime tells, unless it is an object literal. +fn last_written<'a>( + ast_builder: &AstBuilder<'a>, + written: &[Written<'a>], + key: &'static str, +) -> Option> { + let mut value: Option> = None; + for written in written { + let spread = match written { + Written::Prop(prop) => { + value = Some(prop.clone_in(ast_builder.allocator())); + continue; + } + Written::Spread(spread) if may_hold(spread, key) => spread, + Written::Spread(_) => continue, + }; + let member = |optional| { + Expression::StaticMemberExpression(StaticMemberExpression::boxed( + SPAN, + spread.clone_in(ast_builder.allocator()), + IdentifierName::new(SPAN, key, ast_builder), + optional, + ast_builder, + )) + }; + if let Some(written) = written_value(spread, key) { + value = Some(written.clone_in(ast_builder.allocator())); + continue; + } + value = Some(match value { + Some(earlier) if !names(spread, key) => { + let holds = Expression::new_binary_expression( + SPAN, + Expression::new_string_literal(SPAN, key, None, ast_builder), + oxc_syntax::operator::BinaryOperator::In, + crate::utils::wrap_direct_call( + ast_builder, + &Expression::new_identifier(SPAN, "Object", ast_builder), + &[spread.clone_in(ast_builder.allocator())], + ), + ast_builder, + ); + Expression::new_conditional_expression( + SPAN, + holds, + member(false), + earlier, + ast_builder, + ) + } + _ => member(!names(spread, key)), + }); + } + value +} + +/// Whether the object literal `spread` names `key`, so it always holds it +fn names(spread: &Expression<'_>, key: &str) -> bool { + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else { + return false; + }; + object.properties.iter().any(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && get_str_by_property_key(&property.key).as_deref() == Some(key)) + }) +} + +/// What the object literal `spread` sets `key` to, when it writes it by name +/// after anything that may also hold it +fn written_value<'b, 'a>(spread: &'b Expression<'a>, key: &str) -> Option<&'b Expression<'a>> { + let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else { + return None; + }; + object + .properties + .iter() + .rev() + .find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && get_str_by_property_key(&property.key).as_deref() != Some(key) => + { + None + } + ObjectPropertyKind::ObjectProperty(property) if !property.computed => { + Some(Some(&property.value)) + } + _ => Some(None), + }) + .flatten() +} + /// Whether the object `spread` gives may hold `key`: an object literal holds /// only the keys it writes fn may_hold(spread: &Expression<'_>, key: &str) -> bool { @@ -781,10 +841,9 @@ pub fn get_style_expression<'a>( style_prop: &Option>, styles: &[ExtractStyleProp<'a>], style_vars: &Option>, - spread_props: &[Expression<'a>], filename: Option<&str>, ) -> Option> { - let mut style_expressions = Vec::with_capacity(3 + spread_props.len()); + let mut style_expressions = Vec::with_capacity(3); if let Some(style) = gen_styles(ast_builder, styles, filename) { style_expressions.push(style); } @@ -797,20 +856,6 @@ pub fn get_style_expression<'a>( if let Some(style_prop) = style_prop.clone_in(ast_builder.allocator()) { style_expressions.push(style_prop); } - if style_prop.is_none() { - style_expressions.extend(spread_props.iter().filter(|ex| may_hold(ex, "style")).map( - |ex| { - Expression::StaticMemberExpression(StaticMemberExpression::boxed( - SPAN, - ex.clone_in(ast_builder.allocator()), - IdentifierName::new(SPAN, "style", ast_builder), - true, - ast_builder, - )) - }, - )); - } - merge_object_expressions(ast_builder, &style_expressions) } diff --git a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap index 45662cd31..e40622bec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap @@ -5,7 +5,7 @@ expression: outputs [ ( "import { css, styled, Box } from '@devup-ui/react';\nconst myCss = css;\nconst again = myCss;\nconst S = styled;\nconst B = Box;\nexport const a = again({ color: 'red' });\nexport const s = S.div({ color: 'blue' });\nexport const b = ;", - "export const a = \"a-a\";\nexport const s = ({ style, className, ...rest }) =>
;\nexport const b =
;", + "export const a = \"a-a\";\nexport const s = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const b =
;", ), ( "import { css } from '@devup-ui/react';\nexport function f(css) { return css; }\nexport const a = css({ color: 'red' });", diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap index 5325a6ce2..1ee6551c7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap @@ -64,6 +64,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-d\";\nexport const b = `a-e ${card}`;\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = ({ style, className, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, ...rest }) =>

)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-d a-a ${on ? \"\" : card} ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-b\";\nexport const i = \"a-d\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-c ${emotionClass}`;\nexport const n = ;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const e = ({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => )({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap index 12974320c..a6019f8f9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap @@ -35,5 +35,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"a extra c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"extra a c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap index 0288b22d1..1a8c18718 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap @@ -99,5 +99,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =

1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = ({ style, className, ...rest }) =>
;\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =
1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap index d60be1a7c..f50257cbc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap @@ -117,5 +117,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = ({ style, className, ...rest }) =>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap index 5ca82d42b..3cc7ae765 100644 --- a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap +++ b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap @@ -305,6 +305,6 @@ Ok( }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = ({ style, className, ...rest }) =>
;\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = ({ style, className, ...rest }) =>
;\nexport const j = (__devupProps) => (({ style, className, ...rest }) =>
)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n});\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const e = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const j = (__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => )({\n\t...__devupProps,\n\t...{ role: \"button\" }\n});\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\n", }, ) diff --git a/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap b/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap index 20f438589..767876ace 100644 --- a/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap +++ b/libs/extractor/src/snapshots/extractor__tests__create_global_style_collapses_to_a_null_component.snap @@ -38,5 +38,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst GlobalStyle = () => null;\nconst FromObject = () => null;\nconst S = ({ style, className, ...rest }) =>
;\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst GlobalStyle = () => null;\nconst FromObject = () => null;\nconst S = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap index 9643beb44..2a72f6b7e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap +++ b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap @@ -73,7 +73,7 @@ expression: outputs ), ( "", - "((DevupAs0, __devupSpread0) => )([\"div\", \"a\"][i] || \"div\", register(\"x\"));", + "((DevupAs0, __devupSpread0) => )([\"div\", \"a\"][i] || \"div\", register(\"x\"));", ), ( "", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap index c5b7c96d9..3d5796bbd 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -45,5 +45,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = (__devupProps) => (({ style, className, ...rest }) =>
)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n});\nexport const H = ({ style, className, ...rest }) => ;\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"d\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = (__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => )({\n\t...__devupProps,\n\t...{ type: \"button\" }\n});\nexport const H = ({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => ;\nexport const I = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const j = \"d\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap index 6bc7e57e1..394fe7a65 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -166,5 +166,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = ({ style, className, ...rest }) =>
;\nexport const C = ({ style, className, ...rest }) => ;\nexport const D = ({ style, className, ...rest }) =>
;\nexport const E = ({ style, className, ...rest }) =>
;\nexport const F = ({ style, className, ...rest }) =>

;\nexport const spin = \"m\";\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const C = ({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => ;\nexport const D = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const E = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const F = ({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => ;\nexport const spin = \"m\";\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap index 180ee6b42..a89d8668e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nimport { ClassNames } from \"@emotion/react\";\nconst S = ({ style, className, ...rest }) =>

;\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nimport { ClassNames } from \"@emotion/react\";\nconst S = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => ;\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap index 290d709ff..68b4845ca 100644 --- a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap @@ -229,5 +229,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =
;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = `a-aa ${card}`;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =
;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = \"a-aa\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap index 0be1eadf6..7add116c6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap +++ b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap @@ -87,5 +87,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nclass A {\n\t#w = 1;\n\trender(f) {\n\t\treturn ((__devupSpread0) =>
)(f());\n\t}\n}\nexport const a = async (f, X, tag) =>
;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nclass A {\n\t#w = 1;\n\trender(f) {\n\t\treturn ((__devupSpread0) =>
)(f());\n\t}\n}\nexport const a = async (f, X, tag) =>
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap index 91ed4fbf2..7abad9202 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Center} from '@devup-ui/core'\n \n {children}\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Center} from '@devup-ui/core'\n \n {children}\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -97,5 +97,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n {children}\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
\n {children}\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap index eea9d4bdc..899efdf91 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -21,5 +21,5 @@ ToBTreeSet { "buttonS", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\nimport clsx from \"clsx\";\n -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-