From 95d35c823f92fefe5a1364b5bff1aee8cfd279f5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 02:02:12 +0900 Subject: [PATCH 1/3] fix(extractor): compile Emotion's css prop at build time Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_emotion_css_prop.json | 8 + libs/extractor/src/build_time_values.rs | 5 +- libs/extractor/src/composition.rs | 42 + libs/extractor/src/css_prop.rs | 747 ++++++++++++++++++ libs/extractor/src/css_utils.rs | 4 +- .../extractor/extract_style_from_styled.rs | 30 + libs/extractor/src/import_alias_visit.rs | 239 +++++- libs/extractor/src/imported_constants.rs | 137 +++- libs/extractor/src/lib.rs | 230 +++++- libs/extractor/src/mutations.rs | 65 +- libs/extractor/src/prop_modify_utils.rs | 229 +++++- ...css_prop_numbers_runtimes_and_pragmas.snap | 46 ++ ...tests__emotion_css_prop_in_every_form.snap | 583 ++++++++++++++ ...ts__emotion_css_prop_on_every_element.snap | 283 +++++++ ...s_prop_reports_what_it_cannot_compile.snap | 22 + libs/extractor/src/style_values.rs | 8 + libs/extractor/src/utils.rs | 17 + libs/extractor/src/visit.rs | 676 +++++++++++++++- packages/react/package.json | 3 + .../react/src/compat/__tests__/index.test.ts | 9 + packages/react/src/compat/css-prop.d.ts | 7 + packages/react/src/compat/emotion.d.ts | 4 + packages/react/src/compat/index.ts | 21 + 23 files changed, 3302 insertions(+), 113 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_css_prop.json create mode 100644 libs/extractor/src/css_prop.rs create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__css_prop_numbers_runtimes_and_pragmas.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_reports_what_it_cannot_compile.snap create mode 100644 packages/react/src/compat/css-prop.d.ts diff --git a/.changepacks/changepack_log_emotion_css_prop.json b/.changepacks/changepack_log_emotion_css_prop.json new file mode 100644 index 000000000..71e82e1df --- /dev/null +++ b/.changepacks/changepack_log_emotion_css_prop.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 1e8bdd7e3..f9d85cdf9 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -57,14 +57,14 @@ pub(crate) fn has_build_time_values( option: &ExtractOption, resolver: Option<&ModuleResolver>, ) -> bool { - let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits( + let aliased = crate::import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &aliased.code) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -73,6 +73,7 @@ pub(crate) fn has_build_time_values( filename, option, resolver, + aliased.css_prop, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs index 44dae55a6..b59abb6c6 100644 --- a/libs/extractor/src/composition.rs +++ b/libs/extractor/src/composition.rs @@ -173,6 +173,13 @@ impl<'a> Composition<'a> { self.entries.push((key, choice)); } + /// A class applying after the composed parts, whose styles `values` the + /// build knows: what it sets replaces what they set + pub fn cover(&mut self, values: &[ExtractStyleValue]) { + let keys: Vec = values.iter().map(CascadeKey::of).collect(); + self.entries.retain(|(key, _)| !keys.contains(key)); + } + /// The composed styles, for class names and the stylesheet #[must_use] pub fn into_props(self) -> Vec> { @@ -261,6 +268,41 @@ pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { } } +/// Whether `later` may set what `earlier` sets, so the order of their classes +/// decides which applies: a style whose key the build cannot tell may +pub fn overlaps(earlier: &[ExtractStyleProp<'_>], later: &[ExtractStyleProp<'_>]) -> bool { + match (keys(earlier), keys(later)) { + (Some(earlier), Some(later)) => later.iter().any(|key| earlier.contains(key)), + _ => true, + } +} + +/// The keys `props` set, `None` when a style chosen at runtime has none; +/// classes only the runtime gives compose at runtime, so they set none here +fn keys(props: &[ExtractStyleProp<'_>]) -> Option> { + let mut keys = Vec::new(); + for prop in props { + match prop { + ExtractStyleProp::Static(value) => keys.push(CascadeKey::of(value)), + ExtractStyleProp::StaticArray(props) => keys.extend(self::keys(props)?), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + keys.extend(self::keys(std::slice::from_ref(side.as_ref()))?); + } + } + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::Enum { .. } | ExtractStyleProp::MemberExpression { .. } => { + return None; + } + } + } + Some(keys) +} + /// Whether every style `prop` holds has a key, so a condition around it can be /// applied key by key fn keyed(prop: &ExtractStyleProp<'_>) -> bool { diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs new file mode 100644 index 000000000..0ea799d6d --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,747 @@ +//! Emotion's `css` prop: styles an element takes in place of a class name. +//! The build composes them like `css()` and gives the element the classes and +//! CSS variables they compile to, so no runtime reads the prop. + +use std::collections::HashMap; + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, + ObjectPropertyKind, Program, Statement, Str, TemplateElement, TemplateElementValue, + TemplateLiteral, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SPAN; +use rustc_hash::FxHashSet; + +use crate::ImportAlias; +use crate::css_utils::{Place, interpolation_place}; +use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; + +/// The package whose alias makes elements take the `css` prop +pub(crate) const EMOTION_REACT: &str = "@emotion/react"; + +/// Which elements of a file take Emotion's `css` prop +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CssProp { + /// No element: Emotion is not aliased, or the file writes no `css` prop + #[default] + Off, + /// Tags and Devup UI components, which take no `css` prop of their own + Elements, + /// Every element, as the file shows it uses Emotion + Everywhere, +} + +impl CssProp { + /// Which elements of `code` take the prop, with the aliases the build + /// applies, and whether the file shows it uses Emotion + pub(crate) fn of( + import_aliases: &HashMap, + code: &str, + uses_emotion: bool, + ) -> Self { + if !import_aliases.contains_key(EMOTION_REACT) || !may_take_css_prop(code) { + Self::Off + } else if uses_emotion { + Self::Everywhere + } else { + Self::Elements + } + } + + /// Whether the element `name` takes the prop; `devup` tells a Devup UI + /// component + pub(crate) fn takes(self, name: &JSXElementName<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match name { + JSXElementName::Identifier(_) | JSXElementName::NamespacedName(_) => true, + name => crate::imported_constants::jsx_root(name).is_some_and(devup), + }, + } + } + + /// Whether the element a `jsx()` call builds from `element` takes the prop + pub(crate) fn takes_type(self, element: &Expression<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match unwrap_syntax_only(element) { + Expression::StringLiteral(_) => true, + element => binding_root(element).is_some_and(devup), + }, + } + } +} + +/// What in a program takes the `css` prop: the elements `css_prop` tells, and +/// the `jsx()` calls building them +pub(crate) struct CssTakers<'s> { + pub css_prop: CssProp, + /// Local names of the functions building elements from a type and props + jsx: FxHashSet<&'s str>, +} + +impl<'s> CssTakers<'s> { + /// The elements `program` gives a `css` prop, as `css_prop` tells; + /// `compat` is the entry absorbing Emotion's own `jsx` + pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + let mut jsx = FxHashSet::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + for specifier in import.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier + && is_jsx_function(&import.source.value, &specifier.imported.name(), compat) + { + jsx.insert(specifier.local.name.as_str()); + } + } + } + } + Self { css_prop, jsx } + } + + /// Whether `attribute` of the element `name` is a `css` prop it takes; + /// `devup` tells a Devup UI component + pub(crate) fn attribute( + &self, + name: &JSXElementName<'_>, + attribute: &JSXAttributeItem<'_>, + devup: impl Fn(&str) -> bool, + ) -> bool { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) + && self.css_prop.takes(name, devup) + } + + /// Where among the props a `jsx()` call gives is the `css` prop the + /// element takes + pub(crate) fn property( + &self, + call: &CallExpression<'_>, + devup: impl Fn(&str) -> bool, + ) -> Option { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { + return None; + }; + (self.jsx.contains(callee.name.as_str()) + && self.css_prop.takes_type(element.as_expression()?, devup)) + .then(|| { + props.properties.iter().rposition(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed && property.key.static_name().is_some_and(|key| key == "css")) + }) + }) + .flatten() + } +} + +/// Whether `code` may give an element a `css` prop: `css` written as a JSX +/// attribute or as the key of an object +pub(crate) fn may_take_css_prop(code: &str) -> bool { + code.match_indices("css").any(|(index, _)| { + let before = code[..index].chars().next_back(); + let after = code[index + 3..].trim_start(); + let attribute = before.is_some_and(char::is_whitespace) + && after.starts_with('=') + && !after[1..].starts_with('='); + let key = before.is_some_and(|c| c.is_whitespace() || matches!(c, '{' | ',' | '"' | '\'')) + && after + .strip_prefix(['"', '\'']) + .unwrap_or(after) + .trim_start() + .starts_with(':'); + attribute || key + }) +} + +/// Whether importing `source` shows a file uses Emotion +pub(crate) fn is_emotion(source: &str) -> bool { + [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { + source + .strip_prefix(package) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +/// Where the JSX pragma in the comment text `comment` names `@emotion/react` +/// as the module JSX is built with +pub(crate) fn emotion_pragma(comment: &str) -> Option { + let at = comment.find("@jsxImportSource")? + "@jsxImportSource".len(); + let value = comment[at..].trim_start(); + let rest = value.strip_prefix(EMOTION_REACT)?; + rest.chars() + .next() + .is_none_or(|c| c.is_whitespace() || c == '*') + .then(|| comment.len() - value.len()) +} + +/// The React module an Emotion JSX runtime stands in for +pub(crate) fn react_runtime(source: &str) -> Option<&'static str> { + match source { + "@emotion/react/jsx-runtime" => Some("react/jsx-runtime"), + "@emotion/react/jsx-dev-runtime" => Some("react/jsx-dev-runtime"), + _ => None, + } +} + +/// Whether `imported` from `source` builds an element from a type and props; +/// `compat` is the entry absorbing Emotion's own `jsx` +pub(crate) fn is_jsx_function(source: &str, imported: &str, compat: &str) -> bool { + match source { + "react/jsx-runtime" + | "react/jsx-dev-runtime" + | "@emotion/react/jsx-runtime" + | "@emotion/react/jsx-dev-runtime" => matches!(imported, "jsx" | "jsxs" | "jsxDEV"), + source => { + (source == EMOTION_REACT || source == compat) + && matches!(imported, "jsx" | "createElement") + } + } +} + +/// What a function of the theme requires to give rules at build time +pub(crate) const THEME_FUNCTION: &str = + "a function of the theme must give its rules at once, as `theme => ({ ... })`"; + +/// What a function of the theme requires of its reads of the theme +pub(crate) const THEME_READ: &str = "it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets"; + +/// What CSS text requires of an interpolation it cannot place +pub(crate) const UNPLACED: &str = + "an interpolation in CSS text must be a value, or a mixin standing where a declaration would"; + +/// What CSS text requires of a mixin +pub(crate) const NESTED_MIXIN: &str = + "a mixin must stand outside nested rules, where the parts it composes with can be split"; + +/// `function`, a function of the theme, as the rules it gives, each read of +/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// `Err` holds the code the build cannot write so, with what it requires. +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); + let (params, body) = match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let body = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + (&arrow.params, body) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + function.body.as_deref().and_then(returned), + ), + _ => return Err(unsupported()), + }; + let (Some(roots), Some(body)) = (theme_roots(params), body) else { + return Err(unsupported()); + }; + let mut rules = body.clone_in_with_semantic_ids(ast.allocator()); + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(&mut rules, false); + match reads.unread { + Some(unread) => Err((unread, THEME_READ)), + None => Ok(rules), + } +} + +/// What a function body gives when it is a single `return` +pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Expression<'a>> { + match body.statements.as_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_ref(), + _ => None, + } +} + +/// The names a function of the theme reads it by, each with the key of the +/// theme it holds when destructured; `None` for parameters the build cannot +/// follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => Some(Vec::new()), + [param] if param.initializer.is_none() => match ¶m.pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(vec![(identifier.name.as_str(), None)]) + } + BindingPattern::ObjectPattern(object) if object.rest.is_none() => object + .properties + .iter() + .map( + |property| match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(local), Some(key)) + if !property.computed => + { + Some((local.name.as_str(), Some(key.to_string()))) + } + _ => None, + }, + ) + .collect(), + _ => None, + }, + _ => None, + } +} + +/// Writes each read of the theme in a value as the CSS variable it becomes, +/// keeping the first read that is not one +struct ThemeReads<'r, 'a> { + ast: &'r AstBuilder<'a>, + roots: &'r [(&'a str, Option)], + unread: Option>, +} + +/// How an expression reads the theme +enum ThemeRead { + /// By the path of keys to a value + Path(Vec), + /// Otherwise, as a whole or through a call or a key only the runtime gives + Other, +} + +impl<'a> ThemeReads<'_, 'a> { + /// How `expression` reads the theme, when it does + fn path(&self, expression: &Expression<'a>) -> Option { + let mut path = Vec::new(); + let mut exact = true; + let mut cursor = expression; + loop { + match cursor { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match get_string_by_literal_expression(&member.expression) { + Some(key) => path.push(key.into_owned()), + None => exact = false, + } + cursor = &member.object; + } + Expression::CallExpression(call) => { + exact = false; + cursor = &call.callee; + } + Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, + Expression::Identifier(identifier) => { + let (_, key) = self + .roots + .iter() + .find(|(local, _)| *local == identifier.name.as_str())?; + path.extend(key.clone()); + path.reverse(); + return Some(if exact && !path.is_empty() { + ThemeRead::Path(path) + } else { + ThemeRead::Other + }); + } + _ => return None, + } + } + } + + /// Visit `expression`, which stands where a CSS value does when `value` + fn visit(&mut self, expression: &mut Expression<'a>, value: bool) { + match self.path(expression) { + Some(ThemeRead::Path(path)) if value => { + let variable = format!("var(--{})", path.join("-")); + *expression = Expression::new_string_literal( + SPAN, + Str::from_in(variable.as_str(), self.ast.allocator()), + None, + self.ast, + ); + return; + } + Some(_) => { + if self.unread.is_none() { + self.unread = Some(expression.clone_in(self.ast.allocator())); + } + return; + } + None => {} + } + match expression { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = property.key.as_expression_mut() { + self.visit(key, false); + } + self.visit(&mut property.value, true); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.visit(&mut spread.argument, false); + } + } + } + } + Expression::TemplateLiteral(template) => { + for expression in &mut template.expressions { + self.visit(expression, true); + } + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + self.visit(element, value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.visit(&mut conditional.test, false); + self.visit(&mut conditional.consequent, value); + self.visit(&mut conditional.alternate, value); + } + Expression::LogicalExpression(logical) => { + self.visit(&mut logical.left, false); + self.visit(&mut logical.right, value); + } + // Text joined to a string stays text + Expression::BinaryExpression(binary) + if binary.operator == BinaryOperator::Addition + && [&binary.left, &binary.right].into_iter().any(|side| { + matches!( + side, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + }) => + { + self.visit(&mut binary.left, value); + self.visit(&mut binary.right, value); + } + Expression::ParenthesizedExpression(inner) => self.visit(&mut inner.expression, value), + expression => walk_mut::walk_expression(self, expression), + } + } +} + +impl<'a> VisitMut<'a> for ThemeReads<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + self.visit(expression, false); + } +} + +/// CSS text as the parts of a `css` prop: when `mixins`, an interpolation +/// standing where a declaration would is a mixin composed there, splitting the +/// text around it. `Err` holds an interpolation the parts cannot place, with +/// what the text requires of it. +pub(crate) fn template_parts<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + mixins: bool, +) -> Result>, (Expression<'a>, &'static str)> { + let mut parts = Vec::new(); + let mut text = String::new(); + let mut depth = 0usize; + let mut from = 0; + for (index, expression) in template.expressions.iter().enumerate() { + let quasi = template.quasis[index].value.raw.as_str(); + text.push_str(quasi); + depth = (depth + quasi.matches('{').count()).saturating_sub(quasi.matches('}').count()); + let place = interpolation_place(&text, &template.quasis[index + 1..]); + if matches!(place, Place::Value) || get_string_by_literal_expression(expression).is_some() { + continue; + } + let requirement = match place { + Place::Statement if mixins && depth == 0 => { + parts.extend(segment(ast, template, from, index)); + parts.push(expression.clone_in_with_semantic_ids(ast.allocator())); + from = index + 1; + continue; + } + Place::Statement if mixins => NESTED_MIXIN, + _ => UNPLACED, + }; + return Err((expression.clone_in(ast.allocator()), requirement)); + } + parts.extend(segment(ast, template, from, template.expressions.len())); + Ok(parts) +} + +/// The text of `template` from the quasi `from` to the quasi `to`, with the +/// values between them; `None` when it holds nothing +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + from: usize, + to: usize, +) -> Option> { + let quasis = &template.quasis[from..=to]; + if from == to && quasis[0].value.raw.trim().is_empty() { + return None; + } + let allocator = ast.allocator(); + let quasis = quasis.iter().enumerate().map(|(index, quasi)| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: quasi.value.raw, + cooked: quasi.value.cooked, + }, + index == to - from, + ast, + ) + }); + let expressions = template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(allocator)); + Some(Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in(expressions, ast), + ast, + )) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::literal_string_with_formatting_args +)] +mod tests { + use super::*; + use crate::utils::expression_to_code; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + fn parsed<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap() + } + + #[test] + fn test_css_prop_needles() { + for code in [ + "
", + "", + "jsx('div', { css: a })", + "jsx('div', {css:a})", + "jsx('div', {\"css\": a})", + "jsx('div', {'css' : a})", + "f(a,css:b)", + ] { + assert!(may_take_css_prop(code), "{code}"); + } + for code in [ + "import './a.css'", + "a.css = b", + "if (a css == b) {}", + "css(a)", + "
", + "css", + ] { + assert!(!may_take_css_prop(code), "{code}"); + } + } + + #[test] + fn test_css_prop_of_aliases() { + let mut aliases = HashMap::new(); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + assert_eq!( + CssProp::of(&aliases, "
", false), + CssProp::Elements + ); + assert_eq!( + CssProp::of(&aliases, "
", true), + CssProp::Everywhere + ); + } + + #[test] + fn test_emotion_sources_and_pragmas() { + assert!(is_emotion("@emotion/react")); + assert!(is_emotion("@emotion/react/jsx-runtime")); + assert!(is_emotion("@emotion/styled")); + assert!(!is_emotion("@emotion/reactive")); + assert!(!is_emotion("@emotion/css")); + + assert_eq!( + emotion_pragma("* @jsxImportSource @emotion/react "), + Some(19) + ); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/react*"), Some(17)); + assert_eq!( + emotion_pragma(" @jsxImportSource\t@emotion/react"), + Some(18) + ); + assert_eq!(emotion_pragma("@jsxImportSource react"), None); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/reactive"), None); + assert_eq!(emotion_pragma("@jsx jsx"), None); + + assert_eq!( + react_runtime("@emotion/react/jsx-runtime"), + Some("react/jsx-runtime") + ); + assert_eq!( + react_runtime("@emotion/react/jsx-dev-runtime"), + Some("react/jsx-dev-runtime") + ); + assert_eq!(react_runtime("@emotion/react"), None); + + let compat = "@devup-ui/react/compat"; + assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat)); + assert!(is_jsx_function( + "@emotion/react/jsx-dev-runtime", + "jsxDEV", + compat + )); + assert!(!is_jsx_function("react/jsx-runtime", "Fragment", compat)); + assert!(is_jsx_function("@emotion/react", "createElement", compat)); + assert!(is_jsx_function(compat, "jsx", compat)); + assert!(!is_jsx_function("react", "createElement", compat)); + assert!(!is_jsx_function(EMOTION_REACT, "css", compat)); + } + + #[test] + fn test_takes() { + let allocator = Allocator::default(); + let devup = |name: &str| name == "Box"; + for (code, off, elements, everywhere) in [ + ("
", false, true, true), + ("", false, true, true), + ("", false, true, true), + ("", false, false, true), + ("", false, false, true), + ] { + let Expression::JSXElement(element) = parsed(&allocator, code) else { + panic!("{code}"); + }; + let name = &element.opening_element.name; + assert_eq!(CssProp::Off.takes(name, devup), off, "{code}"); + assert_eq!(CssProp::Elements.takes(name, devup), elements, "{code}"); + assert_eq!(CssProp::Everywhere.takes(name, devup), everywhere, "{code}"); + } + for (code, elements) in [ + ("'div'", true), + ("Box", true), + ("Custom", false), + ("f()", false), + ] { + let element = parsed(&allocator, code); + assert!(!CssProp::Off.takes_type(&element, devup), "{code}"); + assert_eq!( + CssProp::Elements.takes_type(&element, devup), + elements, + "{code}" + ); + assert!(CssProp::Everywhere.takes_type(&element, devup), "{code}"); + } + } + + #[test] + fn test_theme_rules() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + for (code, expected) in [ + ( + "theme => ({ color: theme.colors.primary })", + "({color:`var(--colors-primary)`});", + ), + ( + "({ colors, space: s }) => ({ color: colors.text, margin: s[2] })", + "({color:`var(--colors-text)`,margin:`var(--space-2)`});", + ), + ( + "function (t) { return [{ border: `1px solid ${t.line}` }, a && { color: '#' + t.a }]; }", + "[{border:`1px solid ${`var(--line)`}`},a&&{color:`#`+`var(--a)`}];", + ), + ( + "t => c ? { color: (t.a) } : { [k]: t['b'], ...rest }", + "c?{color:`var(--a)`}:{[k]:`var(--b)`,...rest};", + ), + ("() => ({ color: 'red' })", "({color:`red`});"), + ] { + let rules = theme_rules(&ast, &parsed(&allocator, code)).unwrap(); + assert_eq!(expression_to_code(&rules), expected, "{code}"); + } + for (code, unread) in [ + ("t => ({ color: t })", "t"), + ("t => ({ ...t.mixins })", "t.mixins"), + ("t => ({ margin: t.spacing(2) })", "t.spacing(2)"), + ("t => ({ color: t.colors[name] })", "t.colors[name]"), + ("t => t.dark ? {} : {}", "t.dark"), + ("t => ({ width: t.a * 2 })", "t.a"), + ("t => ({ color: t.a || 'red' })", "t.a"), + ("t => ({ color: f(t.a), border: t.b() })", "t.a"), + ] { + let (read, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_READ, "{code}"); + assert_eq!(expression_to_code(&read), format!("{unread};"), "{code}"); + } + for code in [ + "async t => ({})", + "function* (t) { return {}; }", + "async function (t) { return {}; }", + "(t, u) => ({})", + "(...t) => ({})", + "(t = {}) => ({})", + "([t]) => ({})", + "({ a: { b } }) => ({})", + "({ ...t }) => ({})", + "({ [k]: t }) => ({})", + "t => { const a = 1; return {}; }", + "t => {}", + "function (t) {}", + "a", + ] { + let (_, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_FUNCTION, "{code}"); + } + } + + #[test] + fn test_template_parts() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parts = |code: &'static str, mixins: bool| { + let Expression::TemplateLiteral(template) = parsed(&allocator, code) else { + panic!("{code}"); + }; + template_parts(&ast, &template, mixins) + .map(|parts| parts.iter().map(expression_to_code).collect::()) + }; + assert_eq!( + parts("`color: ${c}; ${'margin: 0'};`", false).unwrap(), + "`color: ${c}; ${`margin: 0`};`;" + ); + assert_eq!( + parts("`${base}; color: ${c}; ${other}`", true).unwrap(), + "base;`; color: ${c}; `;other;" + ); + assert_eq!(parts("``", true).unwrap(), ""); + let (_, requirement) = parts("`${base}; color: red;`", false).unwrap_err(); + assert_eq!(requirement, UNPLACED); + let (_, requirement) = parts("`&:hover { ${base}; }`", true).unwrap_err(); + assert_eq!(requirement, NESTED_MIXIN); + let (code, requirement) = parts("`${selector} { color: red; }`", true).unwrap_err(); + assert_eq!(requirement, UNPLACED); + assert_eq!(expression_to_code(&code), "selector;"); + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200b..6ea04a394 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -146,14 +146,14 @@ pub struct TemplateStyles { pub unplaced: Vec, } -enum Place { +pub(crate) enum Place { Value, Statement, Other, } /// Where an interpolation stands, from the CSS written before and after it -fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { +pub(crate) fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); let end = rest.find([';', '{', '}']); diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index cf186e9f1..e5955fb47 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -105,6 +105,36 @@ impl StyledDefinition<'_> { } } +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed)) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + /// The styles the component gives what it renders + #[must_use] + pub fn styles(&self) -> &[ExtractStyleProp<'a>] { + &self.styles + } +} + +/// Whether `prop` holds only values the build knows +fn fixed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(value) => !matches!(value, ExtractStyleValue::Dynamic(_)), + ExtractStyleProp::StaticArray(props) => props.iter().all(fixed), + _ => false, + } +} + /// `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 diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..666fe6b15 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,17 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; +use crate::css_prop::{ + CssProp, EMOTION_REACT, emotion_pragma, is_emotion, is_jsx_function, react_runtime, returned, +}; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, - Statement, + Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, + JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXOpeningElement, LogicalOperator, + ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, @@ -44,6 +47,12 @@ struct LibraryNumbers<'n> { styled: Vec<&'n str>, /// Local names of components taking rules in `styles` (Emotion's `Global`) components: Vec<&'n str>, + /// Which elements take the `css` prop, whose rules are Emotion's + css_prop: CssProp, + /// Local names of Devup UI's exports, which take the `css` prop as tags do + devup: Vec<&'n str>, + /// Local names of the functions building elements from a type and props + jsx: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } @@ -95,6 +104,66 @@ impl LibraryNumbers<'_> { } } + /// The rules a `css` prop composes: a function of the theme gives them + fn pixelify_css(&mut self, value: &Expression) { + match value { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify_css(element); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify_css(&conditional.consequent); + self.pixelify_css(&conditional.alternate); + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_css(&logical.left); + } + self.pixelify_css(&logical.right); + } + Expression::ParenthesizedExpression(inner) => self.pixelify_css(&inner.expression), + Expression::ArrowFunctionExpression(arrow) => { + let rules = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + if let Some(rules) = rules { + self.pixelify_css(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify_css(rules); + } + } + rules => self.pixelify(rules), + } + } + + /// The value of the attribute `name` on `element`, pixelified as `css` + /// prop rules when `css` + fn pixelify_attribute(&mut self, element: &JSXOpeningElement, name: &str) { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|attribute| attribute.name == name) + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value + && let Some(rules) = container.expression.as_expression() + { + if name == "css" { + self.pixelify_css(rules); + } else { + self.pixelify(rules); + } + } + } + } + fn pixelify_value(&mut self, key: &str, value: &Expression) { if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { @@ -155,6 +224,23 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { for rules in rules { self.pixelify(rules); } + if let Expression::Identifier(callee) = &call.callee + && self.jsx.contains(&callee.name.as_str()) + && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() + && let Some(element) = element.as_expression() + && self + .css_prop + .takes_type(element, |root| self.devup.contains(&root)) + { + for property in &props.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && !property.computed + && property.key.static_name().is_some_and(|key| key == "css") + { + self.pixelify_css(&property.value); + } + } + } walk_call_expression(self, call); } @@ -162,19 +248,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { if let JSXElementName::IdentifierReference(name) = &element.name && self.components.contains(&name.name.as_str()) { - for attribute in &element.attributes { - if let JSXAttributeItem::Attribute(attribute) = attribute - && attribute - .name - .as_identifier() - .is_some_and(|name| name.name == "styles") - && let Some(JSXAttributeValue::ExpressionContainer(container)) = - &attribute.value - && let Some(rules) = container.expression.as_expression() - { - self.pixelify(rules); - } - } + self.pixelify_attribute(element, "styles"); + } + if self + .css_prop + .takes(&element.name, |root| self.devup.contains(&root)) + { + self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); } @@ -207,6 +287,8 @@ fn devup_equivalent(source: &str, imported: &str) -> Option ("@vanilla-extract/css", "globalStyle") => Some(DevupTarget::Main("globalCss")), (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), + // The `css` props it builds compile away, leaving React's own element + ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -226,7 +308,18 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits(code, filename, package, import_aliases).code +} + +/// What rewriting aliased imports gives +pub struct Aliased<'a> { + /// The code, or the original code when nothing was rewritten + pub code: Cow<'a, str>, + /// The replacements made in order, so a position in the code maps back to + /// the source + pub edits: Vec, + /// Which elements take Emotion's `css` prop + pub css_prop: CssProp, } /// A replacement of `code[start..end]` by text of `length` bytes @@ -242,17 +335,26 @@ pub type Edit = (usize, usize, usize); /// /// # Returns /// The transformed source code, or the original code if no transformations were -/// needed, and the replacements made in order, so a position in the result maps -/// back to the source +/// needed, the replacements made in order, so a position in the result maps +/// back to the source, and which elements take Emotion's `css` prop pub fn transform_import_aliases_with_edits<'a>( code: &'a str, filename: &str, package: &str, import_aliases: &HashMap, -) -> (Cow<'a, str>, Vec) { - // Quick check: if no aliases match, return original code - if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { - return (Cow::Borrowed(code), Vec::new()); +) -> Aliased<'a> { + let unchanged = |css_prop| Aliased { + code: Cow::Borrowed(code), + edits: Vec::new(), + css_prop, + }; + let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); + // Quick check: if no aliases match and no element takes a `css` prop, + // return original code + if CssProp::of(import_aliases, code, false) == CssProp::Off + && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) + { + return unchanged(CssProp::Off); } let allocator = Allocator::default(); @@ -267,10 +369,51 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); let mut numbers = LibraryNumbers::default(); + let mut uses_emotion = false; + let compat = format!("{package}/compat"); + + // A pragma building JSX with Emotion builds it with React once the `css` + // props are compiled + for comment in &program.comments { + let span = comment.content_span(); + if let Some(at) = emotion_pragma(&code[span.start as usize..span.end as usize]) { + uses_emotion = true; + if emotion_aliased { + let start = span.start as usize + at; + transformations.push((start, start + EMOTION_REACT.len(), "react".to_string())); + } + } + } for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); + uses_emotion |= is_emotion(source_value); + for specifier in import_decl.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if is_jsx_function( + source_value, + &imported_name(&spec.imported), + &compat, + ) => + { + numbers.jsx.push(spec.local.name.as_str()); + } + specifier if source_value == package => { + numbers.devup.push(specifier.local().name.as_str()); + } + _ => {} + } + } + if emotion_aliased && let Some(runtime) = react_runtime(source_value) { + let span = import_decl.source.span; + transformations.push(( + span.start as usize, + span.end as usize, + format!("\"{runtime}\""), + )); + } if let Some(alias) = import_aliases.get(source_value) { let span = import_decl.span; @@ -320,15 +463,18 @@ pub fn transform_import_aliases_with_edits<'a>( } } } - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); + if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) + || numbers.css_prop != CssProp::Off + { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); - transformations.sort_unstable_by_key(|(start, ..)| *start); } + transformations.sort_unstable_by_key(|(start, ..)| *start); // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return (Cow::Borrowed(code), Vec::new()); + return unchanged(numbers.css_prop); } let edits = transformations @@ -340,7 +486,11 @@ pub fn transform_import_aliases_with_edits<'a>( result.replace_range(start..end, &replacement); } - (Cow::Owned(result), edits) + Aliased { + code: Cow::Owned(result), + edits, + css_prop: numbers.css_prop, + } } /// The source offset of `offset` in code `edits` made; an offset inside a @@ -1027,4 +1177,39 @@ const x = 1;", ); } } + + #[test] + fn test_css_prop_numbers_runtimes_and_pragmas() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + let outputs: Vec<(String, CssProp)> = [ + "export const a =
;", + "export const a =
;", + "export const a =
({ top: 1 }), t => { return { left: 2 }; }, t => { f(); return { right: 3 }; }, function (t) { return { bottom: 4 }; }, function (t) { f(); }]} />;", + "import { Box } from '@devup-ui/react';\nexport const a = <>;", + "import styled from '@emotion/styled';\nexport const a = ;", + "/** @jsxImportSource @emotion/react */\nexport const a = ;", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nimport { jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 }, id: 1, ['css']: { top: 1 } }), jsxDEV(Custom, { css: { top: 3 } }), _jsx('div', props), f('div', { css: { top: 4 } })];", + "import { jsx, createElement } from '@emotion/react';\nexport const a = [jsx('div', { css: { padding: 2 } }), createElement('div', null)];", + "import { jsx as _jsx } from 'react/jsx-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 } }), _jsx(Custom, { css: { padding: 2 } })];", + "export const a = { css: 1 };", + ] + .iter() + .map(|code| { + let aliased = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let without_css_prop = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &emotion_alias(), + ); + assert_eq!(without_css_prop.css_prop, CssProp::Off); + assert!(matches!(without_css_prop.code, Cow::Borrowed(_))); + } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 82ad48295..2094e2fea 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -22,6 +22,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; +use crate::css_prop::{CssProp, CssTakers}; use crate::extractor::ExtractResult; use crate::extractor::extract_style_from_expression::{ LiteralHandling, extract_style_from_expression, @@ -320,11 +321,13 @@ pub(crate) fn inline_constants<'a>( filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, ) -> Inlined { let is_style_package = |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; let mut style_roots = FxHashSet::default(); let mut apis = StyleApis::default(); + let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && is_style_package(&import.source.value) @@ -346,11 +349,12 @@ pub(crate) fn inline_constants<'a>( } } } - if style_roots.is_empty() { + if style_roots.is_empty() && css_prop == CssProp::Off { return Inlined::default(); } let mut read = StyleReads { style_roots: &style_roots, + css_props: &css_props, names: FxHashSet::default(), depth: 0, }; @@ -371,6 +375,7 @@ pub(crate) fn inline_constants<'a>( scope .style_names .extend(style_roots.iter().map(ToString::to_string)); + scope.css_props = Some(&css_props); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { @@ -499,9 +504,11 @@ pub(crate) fn inline_constants<'a>( scoping: &scoping, symbols: &symbols, style_roots: &style_roots, + css_props: &css_props, apis: &apis, objects: false, styles: false, + px: false, } .visit_program(program); } @@ -633,6 +640,7 @@ impl StyleApis<'_> { /// of its functions struct StyleReads<'s> { style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, } @@ -671,9 +679,21 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); let style = self.is_style_root(&call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading(style, |reads| { - for argument in &call.arguments { - reads.visit_argument(argument); + for (index, argument) in call.arguments.iter().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter().enumerate() { + reads.reading(at == css, |reads| { + reads.visit_object_property_kind(property); + }); + } + } + _ => reads.visit_argument(argument), + } } }); } @@ -689,20 +709,22 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); - self.reading(style, |reads| { - for attribute in &element.attributes { - match attribute { - JSXAttributeItem::Attribute(attribute) => { - if let Some(value) = &attribute.value { - reads.visit_jsx_attribute_value(value); - } - } - JSXAttributeItem::SpreadAttribute(spread) => { - reads.visit_expression(&spread.argument); + for attribute in &element.attributes { + let style = style + || self.css_props.attribute(&element.name, attribute, |root| { + self.style_roots.contains(root) + }); + self.reading(style, |reads| match attribute { + JSXAttributeItem::Attribute(attribute) => { + if let Some(value) = &attribute.value { + reads.visit_jsx_attribute_value(value); } } - } - }); + JSXAttributeItem::SpreadAttribute(spread) => { + reads.visit_expression(&spread.argument); + } + }); + } } } @@ -1007,6 +1029,8 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, + /// The `css` props of the file extracted, which never run what they hold + css_props: Option<&'p CssTakers<'p>>, uses: Option>>>, changes: FxHashMap>>, } @@ -1022,6 +1046,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), + css_props: None, uses: None, changes: FxHashMap::default(), } @@ -1128,9 +1153,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { uses.clone() } else { let option = modules.option; - let uses = Rc::new(crate::mutations::uses(self.program, &|name| { - self.is_style_import(option, name) - })); + let uses = Rc::new(crate::mutations::uses( + self.program, + &|name| self.is_style_import(option, name), + self.css_props, + )); self.uses = Some(uses.clone()); uses }; @@ -1758,11 +1785,14 @@ struct Inline<'s, 'a> { scoping: &'s Scoping, symbols: &'s FxHashMap, style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop styles: bool, + /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths + px: bool, } impl<'a> Inline<'_, 'a> { @@ -1918,6 +1948,45 @@ impl<'a> Inline<'_, 'a> { self.styles = outer; result } + + /// `visit` reading a `css` prop when `css` + fn reading_css(&mut self, css: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = std::mem::replace(&mut self.px, css); + let result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.px = outer; + result + } +} + +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if let Some(number) = crate::utils::js_number_literal(&property.value) + && number != 0.0 + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + property.value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// The rules `rules` with their numbers as the `px` lengths Emotion reads them +/// as, nested rules included +fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { + if let Expression::ObjectExpression(object) = rules { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + px_value(ast_builder, property); + px_rules(ast_builder, &mut property.value); + } + } + } } /// `constant` written as a literal, objects and arrays too when `objects` @@ -1982,6 +2051,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { .and_then(|constant| self.literal(&constant)) { *expression = literal; + if self.px { + px_rules(self.ast_builder, expression); + } return; } if let Some(chosen) = self.chosen(expression) { @@ -2014,10 +2086,22 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { self.visit_expression(&mut call.callee); let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { - for argument in &mut call.arguments { - inline.visit_argument(argument); + for (index, argument) in call.arguments.iter_mut().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter_mut().enumerate() { + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + } + } + (_, argument) => inline.visit_argument(argument), + } } }); }); @@ -2025,7 +2109,14 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let element_name = &element.name; for attribute in &mut element.attributes { + if self.css_props.attribute(element_name, attribute, |root| { + self.style_roots.contains(root) + }) { + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -2043,9 +2134,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + let inlined_number = self.px + && self.styles + && matches!(self.constant(&property.value), Some(Constant::Number(_))); walk_mut::walk_object_property(self, property); if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } + if inlined_number { + px_value(self.ast_builder, property); + } } } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2557e540e..a9a30a28c 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 component; mod composition; +mod css_prop; mod css_utils; pub mod extract_style; mod extractor; @@ -300,26 +301,32 @@ fn extract_source( // Step 1: Transform import aliases // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` - let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( + let import_alias_visit::Aliased { + code: transformed_code, + edits: alias_edits, + css_prop, + } = import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); - // Step 2: Check if code contains the target package (after transformation) + // Step 2: Check if code contains the target package (after transformation), + // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let unchanged = || ExtractOutput { + styles: FxHashSet::default(), + code: code.to_string(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; - if !has_relevant_import { + if !has_relevant_import && css_prop == css_prop::CssProp::Off && alias_edits.is_empty() { // skip if not using package - return Ok(ExtractOutput { - styles: FxHashSet::default(), - code: code.to_string(), - map: None, - css_file: None, - dependencies: Vec::new(), - }); + return Ok(unchanged()); } let mut dependencies = std::collections::BTreeSet::new(); @@ -427,6 +434,7 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } else { imported_constants::Inlined::default() @@ -443,7 +451,12 @@ fn extract_source( visitor.import_css(inlined.css_styles); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + visitor.takes_css_prop(css_prop); visitor.visit_program(&mut program); + if !has_relevant_import && alias_edits.is_empty() && !visitor.compiled_css_prop { + // No element took the `css` prop the text seemed to give + return Ok(unchanged()); + } if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -14699,7 +14712,7 @@ const Button = styled.button({ bg: 'red' }) )] #[case( "css(yellow, fade)", - r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${fade}`" + r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# )] #[case( "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", @@ -21197,4 +21210,199 @@ export const App = () => ExtractOption { + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + } + } + + fn emotion_outputs(files: &[(&str, &str)]) -> Vec<(String, ToBTreeSet)> { + files + .iter() + .map(|(filename, code)| { + reset_class_map(); + reset_file_map(); + ( + (*code).to_string(), + ToBTreeSet::from(extract(filename, code, emotion_option()).unwrap()), + ) + }) + .collect() + } + + #[test] + #[serial] + fn test_emotion_css_prop_in_every_form() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "export const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ danger }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ active }) =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst style = css`color: red;`;\nexport const App = () =>
;" + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const App = () =>
;" + ), + ( + "test.ts", + "import { jsx } from '@emotion/react';\nexport const App = () => jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });" + ), + ( + "test.ts", + "import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);" + ), + ( + "test.tsx", + "/** @jsx jsx */\nimport { jsx } from '@emotion/react';\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;" + ), + ( + "test.tsx", + "export const App = () =>
{ return { color: colors.text }; }} />;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst base = css({ color: 'blue', margin: 0 });\nexport const App = ({ cls }) => <>
;" + ), + ( + "test.tsx", + "export const App = ({ c, w }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ c }) => <>
;" + ), + ( + "test.tsx", + "const base = { color: 'red', padding: 4 };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "const SPACE = 4;\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];" + ), + ])); + } + + #[test] + #[serial] + fn test_emotion_css_prop_on_every_element() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "const Custom = ({ className }) =>
;\nexport const App = () => ;" + ), + ("test.tsx", "export const a = { css: 1 };"), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Custom = ({ className }) =>
;\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Button = styled.button({ color: 'blue', margin: 1 });\nexport const App = ({ f, rest, cls }) => <>