diff --git a/.changepacks/changepack_log_cT_D5XrKS3_JJG5rRlqgb.json b/.changepacks/changepack_log_cT_D5XrKS3_JJG5rRlqgb.json new file mode 100644 index 00000000..a5471fc8 --- /dev/null +++ b/.changepacks/changepack_log_cT_D5XrKS3_JJG5rRlqgb.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Implement styled", + "date": "2025-12-02T15:06:28.741744300Z" +} diff --git a/Cargo.lock b/Cargo.lock index c2ee70f6..8af2efc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,9 +696,9 @@ dependencies = [ [[package]] name = "oxc_allocator" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd67b29aaa88b44af4d7d7b4752cf1dee9b6bc8b447015aad3470f818c3a24f" +checksum = "5b360908629f56d1b18f60e0aa5a70122fb61e33543078fafbe565edb759d77f" dependencies = [ "allocator-api2", "bumpalo", @@ -709,9 +709,9 @@ dependencies = [ [[package]] name = "oxc_ast" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "508b3bd51231d6ea54f210ddf66d7859998e444deee1aa11490d7af76a8fcc85" +checksum = "89e58ea2b49f8940307bb2d012ca0d2a744f6d98e9f96fc87b0a89c8578d57bf" dependencies = [ "bitflags", "oxc_allocator", @@ -726,9 +726,9 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5175058e196ee9bf6f00bf15f0da5e6ad05b2c649f13f8d231def53eaca4ed7" +checksum = "91c6039e721360dd47a101f7ae1e183f90b3c812cd4ed52e0221d791f70d184a" dependencies = [ "phf", "proc-macro2", @@ -738,9 +738,9 @@ dependencies = [ [[package]] name = "oxc_ast_visit" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3021dc3864afe1e2ec8da4696ea181549dd23e420c54eec8234b8063f96b9711" +checksum = "11ecdea97ef3f0e7ee7d9b0d29327040acfe30c8c4593bdf4e0bc8fea0d75899" dependencies = [ "oxc_allocator", "oxc_ast", @@ -750,9 +750,9 @@ dependencies = [ [[package]] name = "oxc_codegen" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427fe5a089efc28ec4f0ff0ed226447abe3e8a993f5e611b568a5c5f8bb24010" +checksum = "0de87539d889ed530f5811c5c17af31b3346e3b709c1fd6b9e2a5e00c4bac934" dependencies = [ "bitflags", "cow-utils", @@ -771,15 +771,15 @@ dependencies = [ [[package]] name = "oxc_data_structures" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a6c288cce396a89d45998049658911f832c164f5653cf151c17d5d69c8baa15" +checksum = "e0e95d9a0caba623cc004b9d420951a7d490a0cd172912ac776df12a51063353" [[package]] name = "oxc_diagnostics" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c0e9fd547155f3118dc1747389a38ffa4daff2218d229730175c44be66584d" +checksum = "cbf468b479ee17919e8bc11c31c405f059762abb78c90cb0931f5e94d7eac30b" dependencies = [ "cow-utils", "oxc-miette", @@ -788,9 +788,9 @@ dependencies = [ [[package]] name = "oxc_ecmascript" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1b0ea5a50805d783611082c381f0b2703ab9eebce080269342720cc923178f" +checksum = "c5af7a036c4e13de3f0b6bfa7bcf22326c3e1da32210b65ec114c96e17e8d77d" dependencies = [ "cow-utils", "num-bigint", @@ -803,9 +803,9 @@ dependencies = [ [[package]] name = "oxc_estree" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea2e2422277b5177b6e96effc77e9842c074a7d4e27582ae66db121b05a63dc" +checksum = "bb9bac2f3fd66cdb7b538e551d9d72b01ceb9009244c25d370684dce01301114" [[package]] name = "oxc_index" @@ -819,9 +819,9 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "653f0530c06a2deff988cecc378c53c2d3e39a50d48bb3c34170feae6f9340d0" +checksum = "12077275a0b65791602bbd398bc83253328e8ab656a2218a7d6bb571787551f9" dependencies = [ "bitflags", "cow-utils", @@ -842,9 +842,9 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5374512dcbcc68ec232add5fb5386827bd1c3f8472ca4905fff7758d6f4c6b94" +checksum = "b02e8106836d7128a3fac86db05409910c3d96fe46d465cd071877a12802d5b3" dependencies = [ "bitflags", "oxc_allocator", @@ -858,9 +858,9 @@ dependencies = [ [[package]] name = "oxc_semantic" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa86149b02f40f2597b370720ac2396895401e902b204ef8d78b15f7c4f9120e" +checksum = "f474c2a181362369f44a0ffe3bc780b286602c920a8ec166f12d7818664b391a" dependencies = [ "itertools 0.14.0", "oxc_allocator", @@ -892,9 +892,9 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b553fc7cfece123ec1460a063a3bdcc8d39f6f8f358af07f5524227e41d5f63f" +checksum = "4106c63cc7e72fc8b34943b2b85ce1f5350cdd5c7ad70757d1691ac0ebded943" dependencies = [ "compact_str", "oxc-miette", @@ -905,9 +905,9 @@ dependencies = [ [[package]] name = "oxc_syntax" -version = "0.99.0" +version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "becb84744ca2cb19373ea654cc8532cd8623d6aa06cad9164cfc7646b294b0fd" +checksum = "25aa1d1b60990a801ec16f7f984ea148bfbcb330590aeabf3cf639537401ebca" dependencies = [ "bitflags", "cow-utils", diff --git a/apps/next/src/app/page.tsx b/apps/next/src/app/page.tsx index b667af0f..f129aeb0 100644 --- a/apps/next/src/app/page.tsx +++ b/apps/next/src/app/page.tsx @@ -1,7 +1,13 @@ 'use client' -import { Box, css, Text } from '@devup-ui/react' +import { Box, css, styled, Text } from '@devup-ui/react' import { useState } from 'react' +const color = 'yellow' + +const StyledFooter = styled.footer<{ type: '1' | '2' }>` + background-color: ${color}; + color: ${(props) => (props.type === '1' ? 'red' : 'white')}; +` export default function HomePage() { const [color, setColor] = useState('yellow') @@ -9,6 +15,7 @@ export default function HomePage() { return (
+ IMPLEMENTATION~

Result<&str, &str> { + pub fn to_tag(&self) -> &str { match self { ExportVariableKind::Center | ExportVariableKind::VStack | ExportVariableKind::Grid | ExportVariableKind::Flex - | ExportVariableKind::Box => Ok("div"), - ExportVariableKind::Text => Ok("span"), - ExportVariableKind::Image => Ok("img"), - ExportVariableKind::Button => Ok("button"), - ExportVariableKind::Input => Ok("input"), + | ExportVariableKind::Box => "div", + ExportVariableKind::Text => "span", + ExportVariableKind::Image => "img", + ExportVariableKind::Button => "button", + ExportVariableKind::Input => "input", } } } @@ -151,15 +151,15 @@ mod tests { #[test] fn test_to_tag() { - assert_eq!(ExportVariableKind::Box.to_tag(), Ok("div")); - assert_eq!(ExportVariableKind::Text.to_tag(), Ok("span")); - assert_eq!(ExportVariableKind::Image.to_tag(), Ok("img")); - assert_eq!(ExportVariableKind::Button.to_tag(), Ok("button")); - assert_eq!(ExportVariableKind::Input.to_tag(), Ok("input")); - assert_eq!(ExportVariableKind::Flex.to_tag(), Ok("div")); - assert_eq!(ExportVariableKind::VStack.to_tag(), Ok("div")); - assert_eq!(ExportVariableKind::Center.to_tag(), Ok("div")); - assert_eq!(ExportVariableKind::Grid.to_tag(), Ok("div")); + assert_eq!(ExportVariableKind::Box.to_tag(), "div"); + assert_eq!(ExportVariableKind::Text.to_tag(), "span"); + assert_eq!(ExportVariableKind::Image.to_tag(), "img"); + assert_eq!(ExportVariableKind::Button.to_tag(), "button"); + assert_eq!(ExportVariableKind::Input.to_tag(), "input"); + assert_eq!(ExportVariableKind::Flex.to_tag(), "div"); + assert_eq!(ExportVariableKind::VStack.to_tag(), "div"); + assert_eq!(ExportVariableKind::Center.to_tag(), "div"); + assert_eq!(ExportVariableKind::Grid.to_tag(), "div"); } #[test] diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 1ffe19ae..b1f87f15 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -1,12 +1,242 @@ use std::collections::BTreeMap; +use crate::utils::{get_string_by_literal_expression, wrap_direct_call}; use css::{ optimize_multi_css_value::{check_multi_css_optimize, optimize_mutli_css_value}, rm_css_comment::rm_css_comment, style_selector::StyleSelector, }; +use oxc_allocator::Allocator; +use oxc_span::SPAN; -use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::utils::expression_to_code; +use oxc_ast::ast::TemplateLiteral; + +use crate::extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + extract_style_value::ExtractStyleValue, +}; + +#[derive(Debug, PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] +pub enum CssToStyleResult { + Static(ExtractStaticStyle), + Dynamic(ExtractDynamicStyle), +} + +impl From for ExtractStyleValue { + fn from(value: CssToStyleResult) -> Self { + match value { + CssToStyleResult::Static(style) => ExtractStyleValue::Static(style), + CssToStyleResult::Dynamic(style) => ExtractStyleValue::Dynamic(style), + } + } +} + +pub fn rm_last_semi_colon(code: &str) -> &str { + code.trim_end_matches(';') +} + +pub fn css_to_style_literal<'a>( + css: &TemplateLiteral<'a>, + level: u8, + selector: &Option, +) -> Vec { + let mut styles = vec![]; + + // If there are no expressions, just process quasis as static CSS + if css.expressions.is_empty() { + for quasi in css.quasis.iter() { + styles.extend( + css_to_style(&quasi.value.raw, level, selector) + .into_iter() + .map(CssToStyleResult::Static), + ); + } + return styles; + } + + // Process template literal with expressions + // Template literal format: `text ${expr1} text ${expr2} text` + // We need to parse CSS and identify where expressions are used + + // Build a combined CSS string with unique placeholders for expressions + // Use a format that won't conflict with actual CSS values + let mut css_parts = Vec::new(); + let mut expression_map = std::collections::HashMap::new(); + + for (i, quasi) in css.quasis.iter().enumerate() { + css_parts.push(quasi.value.raw.to_string()); + + // Add expression placeholder if not the last quasi + if i < css.expressions.len() { + // Use a unique placeholder format that CSS parser won't modify + let placeholder = format!("__EXPR_{}__", i); + expression_map.insert(placeholder.clone(), i); + css_parts.push(placeholder); + } + } + + let combined_css = css_parts.join(""); + + // Parse CSS to extract static styles + let static_styles = css_to_style(&combined_css, level, selector); + + // Process each static style and check if it contains expression placeholders + for style in static_styles { + let value = style.value(); + + // Find all placeholders in this value + let mut found_placeholders = Vec::new(); + for (placeholder, &idx) in expression_map.iter() { + if value.contains(placeholder) { + found_placeholders.push((placeholder.clone(), idx)); + } + } + + if !found_placeholders.is_empty() { + // Check if all expressions are literals that can be statically evaluated + + let mut all_literals = true; + let mut literal_values = Vec::new(); + + let mut iter = found_placeholders.iter(); + while all_literals && let Some((_, idx)) = iter.next() { + if *idx < css.expressions.len() + && let Some(literal_value) = + get_string_by_literal_expression(&css.expressions[*idx]) + { + literal_values.push((*idx, literal_value)); + } else { + all_literals = false; + } + } + + if all_literals { + // All expressions are literals - replace placeholders with literal values to create static style + let mut static_value = value.to_string(); + for (placeholder, idx) in &found_placeholders { + if let Some((_, literal_value)) = literal_values.iter().find(|(i, _)| i == idx) + { + static_value = + static_value.replace(placeholder.as_str(), literal_value.as_str()); + } + } + // Create a new static style with the evaluated value + styles.push(CssToStyleResult::Static(ExtractStaticStyle::new( + style.property(), + &static_value, + style.level(), + style.selector().cloned(), + ))); + } else { + // Not all expressions are literals - need to create dynamic style + // Check if value is just a placeholder (no surrounding text) + if found_placeholders.len() == 1 + && let (placeholder, idx) = &found_placeholders[0] + && value.trim() == placeholder.as_str() + && *idx < css.expressions.len() + { + // Value is just the expression - use expression code directly + let expr = &css.expressions[*idx]; + + // Check if expression is a function (arrow function or function expression) + let is_function = matches!( + expr, + oxc_ast::ast::Expression::ArrowFunctionExpression(_) + | oxc_ast::ast::Expression::FunctionExpression(_) + ); + + let allocator = Allocator::default(); + let ast_builder = oxc_ast::AstBuilder::new(&allocator); + let identifier = if is_function { + expression_to_code(&wrap_direct_call( + &ast_builder, + expr, + &[ast_builder.expression_identifier(SPAN, ast_builder.atom("rest"))], + )) + } else { + expression_to_code(expr) + }; + let identifier = rm_last_semi_colon(&identifier); + + styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( + style.property(), + style.level(), + identifier, + style.selector().cloned(), + ))); + } else { + // Value has surrounding text - need to create template literal + // Reconstruct the template literal by replacing placeholders with ${expr} syntax + // The value contains placeholders like "__EXPR_0__px", we need to convert to `${expr}px` + + let mut template_literal = value.to_string(); + + // Sort placeholders by their position in reverse order to avoid index shifting + found_placeholders.sort_by(|(a_placeholder, _), (b_placeholder, _)| { + template_literal + .rfind(a_placeholder) + .cmp(&template_literal.rfind(b_placeholder)) + }); + + // Replace each placeholder with the actual expression in template literal format + for (placeholder, idx) in &found_placeholders { + if *idx < css.expressions.len() { + let expr = &css.expressions[*idx]; + + // Check if expression is a function (arrow function or function expression) + let is_function = matches!( + expr, + oxc_ast::ast::Expression::ArrowFunctionExpression(_) + | oxc_ast::ast::Expression::FunctionExpression(_) + ); + + let allocator = Allocator::default(); + let ast_builder = oxc_ast::AstBuilder::new(&allocator); + let expr_code = if is_function { + expression_to_code(&wrap_direct_call( + &ast_builder, + expr, + &[ast_builder + .expression_identifier(SPAN, ast_builder.atom("rest"))], + )) + } else { + expression_to_code(expr) + }; + + let expr_code = rm_last_semi_colon(&expr_code); + // Replace placeholder with ${expr} syntax + template_literal = template_literal + .replace(placeholder.as_str(), &format!("${{{}}}", expr_code)); + } + } + + // Wrap in template literal backticks + let final_identifier = format!("`{}`", template_literal); + + styles.push(CssToStyleResult::Dynamic(ExtractDynamicStyle::new( + style.property(), + style.level(), + &final_identifier, + style.selector().cloned(), + ))); + } + } + } else { + // Check if property name contains a dynamic expression placeholder + let property = style.property(); + + if !expression_map.keys().any(|p| property.contains(p)) { + // Static style + styles.push(CssToStyleResult::Static(style)); + } + + // Property name is dynamic - skip for now as it's more complex + } + } + + styles +} pub fn css_to_style( css: &str, @@ -22,9 +252,10 @@ pub fn css_to_style( .flat_map(|s| { let s = s.trim(); if s.is_empty() { - return None; + None + } else { + Some(format!("@media{s}")) } - Some(format!("@media{s}")) }) .collect::>(); if media_inputs.len() > 1 { @@ -37,16 +268,62 @@ pub fn css_to_style( if input.contains('{') { while let Some(start) = input.find('{') { - let rest = &input[start + 1..]; + // Check if there are properties before the selector + let before_brace = &input[..start].trim(); + + // Split by semicolon to find the last part which should be the selector + let parts: Vec<&str> = before_brace.split(';').map(|s| s.trim()).collect(); - let end = if selector.is_none() { - rest.rfind('}').unwrap() + // Find the selector part (the last part that doesn't contain ':') + // or if all parts contain ':', then the last part is the selector + let (plain_props, selector_part) = if parts.len() > 1 { + // Check if any part doesn't contain ':' (which would be a selector) + let mut selector_idx = parts.len(); + for (i, part) in parts.iter().enumerate().rev() { + if !part.contains(':') || part.starts_with('&') || part.starts_with('@') { + selector_idx = i; + break; + } + } + + // Math.min + let (props, sel) = parts.split_at(parts.len().min(selector_idx)); + (props.join(";"), sel.join(";")) } else { - rest.find('}').unwrap() + ("".to_string(), before_brace.to_string()) }; + + // Process plain properties if any + if !plain_props.is_empty() { + styles.extend(css_to_style_block(&plain_props, level, selector)); + } + + let rest = &input[start + 1..]; + + // Find the matching closing brace by counting braces + let mut brace_count = 1; + let mut end = 0; + for (i, ch) in rest.char_indices() { + match ch { + '{' => brace_count += 1, + '}' => { + brace_count -= 1; + if brace_count == 0 { + end = i; + break; + } + } + _ => {} + } + } + + // If we didn't find a matching brace, use the first '}' as fallback + if brace_count > 0 { + end = rest.find('}').unwrap_or(rest.len()); + } let block = &rest[..end]; let sel = &if let Some(StyleSelector::Media { query, .. }) = selector { - let local_sel = input[..start].trim().to_string(); + let local_sel = selector_part.trim().to_string(); Some(StyleSelector::Media { query: query.clone(), selector: if local_sel == "&" { @@ -56,13 +333,15 @@ pub fn css_to_style( }, }) } else { - let sel = input[..start].trim().to_string(); + let sel = selector_part.trim().to_string(); if sel.starts_with("@media") { Some(StyleSelector::Media { query: sel.replace(" ", "").replace("and(", "and (")["@media".len()..] .to_string(), selector: None, }) + } else if sel.is_empty() { + selector.clone() } else { Some(StyleSelector::Selector(sel)) } @@ -72,10 +351,34 @@ pub fn css_to_style( } else { css_to_style_block(block, level, sel) }; - let input_end = input.rfind('}').unwrap() + 1; - input = &input[start + end + 2..input_end]; + // Find the matching closing brace + let closing_brace_pos = start + 1 + end; + + // Process the block styles.extend(block); + + // Update input to continue processing after the closing brace + // Check if there's more content after the closing brace + if closing_brace_pos + 1 < input.len() { + let remaining = &input[closing_brace_pos + 1..].trim(); + if !remaining.is_empty() { + // If there's remaining text after the closing brace, process it + // This handles cases like "} color: blue;" + if remaining.contains('{') { + // If it contains '{', continue the loop + input = remaining; + } else { + // If it doesn't contain '{', process it as a block and break + styles.extend(css_to_style_block(remaining, level, selector)); + break; + } + } else { + break; + } + } else { + break; + } } } else { styles.extend(css_to_style_block(input, level, selector)); @@ -182,8 +485,362 @@ pub fn optimize_css_block(css: &str) -> String { mod tests { use super::*; + use oxc_allocator::Allocator; + use oxc_ast::ast::{Expression, Statement}; + use oxc_parser::Parser; + use oxc_span::SourceType; use rstest::rstest; + #[rstest] + #[case("`background-color: red;`", vec![("background-color", "red", None)])] + #[case("`background-color: ${color};`", vec![("background-color", "color", None)])] + #[case("`background-color: ${color}`", vec![("background-color", "color", None)])] + #[case("`background-color: ${color};color: blue;`", vec![("background-color", "color", None), ("color", "blue", None)])] + #[case("`background-color: ${()=>\"arrow dynamic\"}`", vec![("background-color", "(()=>`arrow dynamic`)(rest)", None)])] + #[case("`background-color: ${()=>\"arrow dynamic\"};color: blue;`", vec![("background-color", "(()=>`arrow dynamic`)(rest)", None), ("color", "blue", None)])] + #[case("`color: blue;background-color: ${()=>\"arrow dynamic\"};`", vec![("color", "blue", None),("background-color", "(()=>`arrow dynamic`)(rest)", None)])] + #[case("`background-color: ${function(){ return \"arrow dynamic\"}}`", vec![("background-color", "(function(){return`arrow dynamic`})(rest)", None)])] + #[case("`background-color: ${function () { return \"arrow dynamic\"} }`", vec![("background-color", "(function(){return`arrow dynamic`})(rest)", None)])] + #[case("`background-color: ${object.color}`", vec![("background-color", "object.color", None)])] + #[case("`background-color: ${object['color']}`", vec![("background-color", "object[`color`]", None)])] + #[case("`background-color: ${func()}`", vec![("background-color", "func()", None)])] + #[case("`background-color: ${(props)=>props.b ? 'a' : 'b'}`", vec![("background-color", "(props=>props.b?`a`:`b`)(rest)", None)])] + #[case("`background-color: ${(props)=>props.b ? null : undefined}`", vec![("background-color", "(props=>props.b?null:undefined)(rest)", None)])] + #[case( + "`color: red; background: blue;`", + vec![ + ("color", "red", None), + ("background", "blue", None), + ] + )] + #[case( + "`margin:0;padding:0;`", + vec![ + ("margin", "0", None), + ("padding", "0", None), + ] + )] + #[case( + "`font-size: 16px;`", + vec![ + ("font-size", "16px", None), + ] + )] + #[case( + "`border: 1px solid #000; color: #fff;`", + vec![ + ("border", "1px solid #000", None), + ("color", "#FFF", None), + ] + )] + #[case( + "``", + vec![] + )] + #[case( + "`@media (min-width: 768px) { + border: 1px solid #000; + color: #fff; + }`", + vec![ + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ] + )] + #[case( + "`@media (min-width: 768px) and (max-width: 1024px) { + border: 1px solid #000; + color: #fff; + } + + @media (min-width: 768px) { + border: 1px solid #000; + color: #fff; + }`", + vec![ + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)and (max-width:1024px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)and (max-width:1024px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ] + )] + #[case( + "`@media (min-width: 768px) { + & { + border: 1px solid #fff; + color: #fff; + } + &:hover, &:active, &:nth-child(2) { + border: 1px solid #000; + color: #000; + } + }`", + vec![ + ("border", "1px solid #FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + })), + ("color", "#000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), + })), + ] + )] + #[case( + "`@media (min-width: 768px) { + & { + border: 1px solid #fff; + color: #fff; + } + &:hover { + border: 1px solid #000; + color: #000; + } + }`", + vec![ + ("border", "1px solid #FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ("color", "#000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ] + )] + #[case( + "`@media (min-width: 768px) { + & { + border: 1px solid #fff; + color: #fff; + } + &:hover { + border: 1px solid #000; + color: #000; + } + } + @media (max-width: 768px) and (min-width: 480px) { + & { + border: 1px solid #fff; + color: #fff; + } + &:hover { + border: 1px solid #000; + color: #000; + } + }`", + vec![ + ("border", "1px solid #FFF", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ("color", "#000", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ("border", "1px solid #FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ("color", "#000", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: Some("&:hover".to_string()), + })), + ] + )] + #[case( + "`@media (min-width: 768px) { + & { + border: 1px solid #fff; + color: #fff; + } + } + @media (max-width: 768px) and (min-width: 480px) { + border: 1px solid #000; + color: #000; + }`", + vec![ + ("border", "1px solid #FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("color", "#FFF", Some(StyleSelector::Media { + query: "(min-width:768px)".to_string(), + selector: None, + })), + ("border", "1px solid #000", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: None, + })), + ("color", "#000", Some(StyleSelector::Media { + query: "(max-width:768px)and (min-width:480px)".to_string(), + selector: None, + })), + ] + )] + #[case( + "`@media (min-width: 768px) { + & { + } + } + @media (max-width: 768px) and (min-width: 480px) { + }`", + vec![] + )] + #[case( + "`ul { font-family: 'Roboto Hello', sans-serif; }`", + vec![ + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ] + )] + #[case( + "`&:hover { background-color: red; }`", + vec![ + ("background-color", "red", Some(StyleSelector::Selector("&:hover".to_string()))), + ] + )] + #[case( + "`background-color: red; &:hover { background-color: red; }`", + vec![ + ("background-color", "red", None), + ("background-color", "red", Some(StyleSelector::Selector("&:hover".to_string()))), + ] + )] + #[case( + "`background-color: red; &:hover { background-color: red; } color: blue;`", + vec![ + ("background-color", "red", None), + ("background-color", "red", Some(StyleSelector::Selector("&:hover".to_string()))), + ("color", "blue", None), + ] + )] + #[case( + "`background-color: red; &:hover { background-color: red; } color: blue; &:active { background-color: blue; }`", + vec![ + ("background-color", "red", None), + ("background-color", "red", Some(StyleSelector::Selector("&:hover".to_string()))), + ("color", "blue", None), + ("background-color", "blue", Some(StyleSelector::Selector("&:active".to_string()))), + ] + )] + #[case( + "`background-color: red; &:hover { background-color: red; } color: blue; &:active { background-color: blue; } transform: rotate(90deg);`", + vec![ + ("background-color", "red", None), + ("background-color", "red", Some(StyleSelector::Selector("&:hover".to_string()))), + ("color", "blue", None), + ("background-color", "blue", Some(StyleSelector::Selector("&:active".to_string()))), + ("transform", "rotate(90deg)", None), + ] + )] + #[case("`width: ${1}px;`", vec![("width", "1px", None)])] + #[case("`width: ${\"1\"}px;`", vec![("width", "1px", None)])] + #[case("`width: ${'1'}px;`", vec![("width", "1px", None)])] + #[case("`width: ${`1`}px;`", vec![("width", "1px", None)])] + #[case("`width: ${\"1px\"};`", vec![("width", "1px", None)])] + #[case("`width: ${'1px'};`", vec![("width", "1px", None)])] + #[case("`width: ${`1px`};`", vec![("width", "1px", None)])] + #[case("`width: ${1 + 1}px;`", vec![("width", "`${1+1}px`", None)])] + #[case("`width: ${func(1)}px;`", vec![("width", "`${func(1)}px`", None)])] + #[case("`width: ${func(1)}${2}px;`", vec![("width", "`${func(1)}${2}px`", None)])] + #[case("`width: ${1}${2}px;`", vec![("width", "12px", None)])] + #[case("`width: ${func(\n\t 1 , \n\t2\n)}px;`", vec![("width", "`${func(1,2)}px`", None)])] + #[case("`width: ${func(\" wow \")}px;`", vec![("width", "`${func(` wow `)}px`", None)])] + #[case("`width: ${func(\"hello\\nworld\")}px;`", vec![("width", "`${func(`hello\nworld`)}px`", None)])] + #[case("`width: ${func('test\\'quote')}px;`", vec![("width", "`${func(`test'quote`)}px`", None)])] + #[case("`width: ${(props)=>props.b ? \"hello\\\"world\" : \"test\"}px;`", vec![("width", "`${(props=>props.b?`hello\"world`:`test`)(rest)}px`", None)])] + #[case("`width: ${(props)=>props.b ? \"hello\\\"world\\\"more\" : \"test\"}px;`", vec![("width", "`${(props=>props.b?`hello\"world\"more`:`test`)(rest)}px`", None)])] + #[case("`width: ${(props)=>props.b ? \"hello\" + \"world\" : \"test\"}px;`", vec![("width", "`${(props=>props.b?`hello`+`world`:`test`)(rest)}px`", None)])] + // wrong cases + #[case( + "`@media (min-width: 768px) { + & { + `", + vec![] + )] + fn test_css_to_style_literal( + #[case] input: &str, + #[case] expected: Vec<(&str, &str, Option)>, + ) { + // parse template literal code + let allocator = Allocator::default(); + let css = Parser::new(&allocator, input, SourceType::ts()).parse(); + if let Statement::ExpressionStatement(expr) = &css.program.body[0] + && let Expression::TemplateLiteral(tmp) = &expr.expression + { + let styles = css_to_style_literal(tmp, 0, &None); + let mut result: Vec<(&str, &str, Option)> = styles + .iter() + .map(|prop| match prop { + CssToStyleResult::Static(style) => { + (style.property(), style.value(), style.selector().cloned()) + } + CssToStyleResult::Dynamic(dynamic) => ( + dynamic.property(), + dynamic.identifier(), + dynamic.selector().cloned(), + ), + }) + .collect(); + result.sort(); + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + assert_eq!(result, expected_sorted); + } else { + panic!("not a template literal"); + } + } + #[rstest] #[case( "div{ @@ -482,6 +1139,13 @@ mod tests { ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), ] )] + #[case( + "div { color: red; ; { background: blue; } }", + vec![ + ("color", "red", Some(StyleSelector::Selector("div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ] + )] fn test_css_to_style( #[case] input: &str, #[case] expected: Vec<(&str, &str, Option)>, diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 70d8498e..6c577aa7 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use crate::{ ExtractStyleProp, - css_utils::css_to_style, + css_utils::{CssToStyleResult, css_to_style_literal}, extract_style::{ extract_font_face::ExtractFontFace, extract_import::ExtractImport, extract_style_value::ExtractStyleValue, @@ -116,18 +116,16 @@ pub fn extract_global_style_from_expression<'a>( file: file.to_string(), }))); } else if let ArrayExpressionElement::TemplateLiteral(t) = p { - let css_styles = css_to_style( - t.quasis - .iter() - .map(|q| q.value.raw.as_str()) - .collect::() - .trim(), - 0, - &None, - ) - .into_iter() - .map(ExtractStyleValue::Static) - .collect::>(); + let css_styles = css_to_style_literal(t, 0, &None) + .into_iter() + .filter_map(|ex| { + if let CssToStyleResult::Static(st) = ex { + Some(ExtractStyleValue::Static(st)) + } else { + None + } + }) + .collect::>(); styles.push(ExtractStyleProp::Static( ExtractStyleValue::FontFace(ExtractFontFace { properties: BTreeMap::from_iter( diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index c78973d8..3c878909 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1,6 +1,6 @@ use crate::{ ExtractStyleProp, - css_utils::css_to_style, + css_utils::{css_to_style, css_to_style_literal}, extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, @@ -145,17 +145,10 @@ pub fn extract_style_from_expression<'a>( &None, ), Expression::TemplateLiteral(tmp) => ExtractResult { - styles: css_to_style( - &tmp.quasis - .iter() - .map(|q| q.value.raw.as_str()) - .collect::(), - level, - selector, - ) - .into_iter() - .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) - .collect(), + styles: css_to_style_literal(tmp, level, selector) + .into_iter() + .map(|ex| ExtractStyleProp::Static(ex.into())) + .collect(), ..ExtractResult::default() }, _ => ExtractResult::default(), diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs new file mode 100644 index 00000000..7872abfa --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -0,0 +1,349 @@ +use std::collections::HashMap; + +use crate::{ + ExtractStyleProp, + component::ExportVariableKind, + css_utils::css_to_style_literal, + extract_style::extract_style_value::ExtractStyleValue, + extractor::{ExtractResult, extract_style_from_expression::extract_style_from_expression}, + gen_class_name::gen_class_names, + gen_style::gen_styles, + utils::{merge_object_expressions, wrap_array_filter}, +}; +use oxc_allocator::CloneIn; +use oxc_ast::{ + AstBuilder, + ast::{Argument, Expression, FormalParameterKind}, +}; +use oxc_span::SPAN; + +fn extract_base_tag_and_class_name<'a>( + input: &Expression<'a>, + imports: &HashMap, +) -> (Option, Option>) { + if let Expression::StaticMemberExpression(member) = input { + (Some(member.property.name.to_string()), None) + } else if let Expression::CallExpression(call) = input + && call.arguments.len() == 1 + { + // styled("div") or styled(Component) + if let Argument::StringLiteral(lit) = &call.arguments[0] { + (Some(lit.value.to_string()), None) + } else if let Argument::Identifier(ident) = &call.arguments[0] { + if let Some(export_variable_kind) = imports.get(ident.name.as_str()) { + ( + Some(export_variable_kind.to_tag().to_string()), + Some(export_variable_kind.extract()), + ) + } else { + (Some(ident.name.to_string()), None) + } + } else { + // Component reference - we'll handle this later + (None, None) + } + } else { + (None, None) + } +} + +/// Extract styles from styled function calls +/// Handles patterns like: +/// - styled.div`css` +/// - styled("div")`css` +/// - styled("div")({ bg: "red" }) +/// - styled.div({ bg: "red" }) +/// - styled(Component)({ bg: "red" }) +pub fn extract_style_from_styled<'a>( + ast_builder: &AstBuilder<'a>, + expression: &mut Expression<'a>, + split_filename: Option<&str>, + imports: &HashMap, +) -> (ExtractResult<'a>, Expression<'a>) { + let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression + && let (Some(tag_name), default_class_name) = + extract_base_tag_and_class_name(&tag.tag, imports) + { + // Case 1: styled.div`css` or styled("div")`css` + // Check if tag is styled.div or styled(...) + // Extract CSS from template literal + + let styles = css_to_style_literal(&tag.quasi, 0, &None); + let mut props_styles: Vec> = styles + .iter() + .map(|ex| ExtractStyleProp::Static(ex.clone().into())) + .collect(); + + if let Some(default_class_name) = default_class_name { + props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + } + + let class_name = gen_class_names(ast_builder, &mut props_styles, None, split_filename); + let styled_component = create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &props_styles, None), + ); + + let result = ExtractResult { + styles: props_styles, + tag: Some(ast_builder.expression_string_literal( + SPAN, + ast_builder.atom(&tag_name), + None, + )), + style_order: None, + style_vars: None, + props: None, + }; + + (Some(result), Some(styled_component)) + } else if let Expression::CallExpression(call) = expression + && let (Some(tag_name), default_class_name) = + extract_base_tag_and_class_name(&call.callee, imports) + && call.arguments.len() == 1 + { + // Case 2: styled.div({ bg: "red" }) or styled("div")({ bg: "red" }) + // Check if this is a call to styled.div or styled("div") + + // Extract styles from object expression + let ExtractResult { + mut styles, + style_order, + style_vars, + props, + .. + } = extract_style_from_expression( + ast_builder, + None, + if let Argument::SpreadElement(spread) = &mut call.arguments[0] { + &mut spread.argument + } else { + call.arguments[0].to_expression_mut() + }, + 0, + &None, + ); + if let Some(default_class_name) = default_class_name { + styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + } + + let class_name = gen_class_names(ast_builder, &mut styles, style_order, split_filename); + let styled_component = create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &styles, None), + ); + + let result = ExtractResult { + styles, + tag: None, + style_order, + style_vars, + props, + }; + + (Some(result), Some(styled_component)) + } else { + (None, None) + }; + ( + result.unwrap_or(ExtractResult::default()), + new_expr.unwrap_or(expression.clone_in(ast_builder.allocator)), + ) +} + +fn create_styled_component<'a>( + ast_builder: &AstBuilder<'a>, + tag_name: &str, + class_name: &Option>, + style_vars: &Option>, +) -> Expression<'a> { + let params = ast_builder.formal_parameters( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in( + vec![ast_builder.formal_parameter( + SPAN, + oxc_allocator::Vec::from_iter_in(vec![], ast_builder.allocator), + ast_builder.binding_pattern( + ast_builder.binding_pattern_kind_object_pattern( + SPAN, + oxc_allocator::Vec::from_iter_in( + vec![ + ast_builder.binding_property( + SPAN, + ast_builder.property_key_static_identifier(SPAN, "style"), + ast_builder.binding_pattern( + ast_builder.binding_pattern_kind_binding_identifier( + SPAN, "style", + ), + None::< + oxc_allocator::Box< + oxc_ast::ast::TSTypeAnnotation<'a>, + >, + >, + false, + ), + true, + false, + ), + ast_builder.binding_property( + SPAN, + ast_builder + .property_key_static_identifier(SPAN, "className"), + ast_builder.binding_pattern( + ast_builder.binding_pattern_kind_binding_identifier( + SPAN, + "className", + ), + None::< + oxc_allocator::Box< + oxc_ast::ast::TSTypeAnnotation<'a>, + >, + >, + false, + ), + true, + false, + ), + ], + ast_builder.allocator, + ), + Some(ast_builder.binding_rest_element( + SPAN, + ast_builder.binding_pattern( + ast_builder.binding_pattern_kind_binding_identifier( + SPAN, + ast_builder.atom("rest"), + ), + None::>>, + false, + ), + )), + ), + None::>>, + false, + ), + None, + false, + false, + )], + ast_builder.allocator, + ), + None::>>, + ); + let body = ast_builder.alloc_function_body( + SPAN, + oxc_allocator::Vec::from_iter_in(vec![], ast_builder.allocator), + oxc_allocator::Vec::from_iter_in( + vec![ast_builder.statement_expression( + SPAN, + ast_builder.expression_jsx_element( + SPAN, + ast_builder.alloc_jsx_opening_element( + SPAN, + ast_builder.jsx_element_name_identifier(SPAN, ast_builder.atom(tag_name)), + None::>>, + oxc_allocator::Vec::from_iter_in( + vec![ + ast_builder.jsx_attribute_item_spread_attribute( + SPAN, + ast_builder + .expression_identifier(SPAN, ast_builder.atom("rest")), + ), + ast_builder.jsx_attribute_item_attribute( + SPAN, + ast_builder.jsx_attribute_name_identifier( + SPAN, + ast_builder.atom("className"), + ), + Some( + ast_builder.jsx_attribute_value_expression_container( + SPAN, + class_name + .as_ref() + .map(|name| { + wrap_array_filter( + ast_builder, + &[ + name.clone_in( + ast_builder.allocator, + ), + ast_builder.expression_identifier( + SPAN, + ast_builder.atom("className"), + ), + ], + ) + .unwrap() + }) + .unwrap_or_else(|| { + ast_builder.expression_identifier( + SPAN, + ast_builder.atom("className"), + ) + }) + .into(), + ), + ), + ), + ast_builder.jsx_attribute_item_attribute( + SPAN, + ast_builder.jsx_attribute_name_identifier( + SPAN, + ast_builder.atom("style"), + ), + Some( + ast_builder.jsx_attribute_value_expression_container( + SPAN, + style_vars + .as_ref() + .map(|style_vars| { + merge_object_expressions( + ast_builder, + &[ + style_vars.clone_in( + ast_builder.allocator, + ), + ast_builder.expression_identifier( + SPAN, + ast_builder.atom("style"), + ), + ], + ) + .unwrap() + }) + .unwrap_or_else(|| { + ast_builder.expression_identifier( + SPAN, + ast_builder.atom("style"), + ) + }) + .into(), + ), + ), + ), + ], + ast_builder.allocator, + ), + ), + oxc_allocator::Vec::from_iter_in(vec![], ast_builder.allocator), + None::>>, + ), + )], + ast_builder.allocator, + ), + ); + ast_builder.expression_arrow_function( + SPAN, + true, + false, + None::>>, + params, + None::>>, + body, + ) +} diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index e467d099..e97bb3ab 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -7,6 +7,7 @@ pub(super) mod extract_keyframes_from_expression; pub(super) mod extract_style_from_expression; pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; +pub(super) mod extract_style_from_styled; /** * type diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4d8a11ee..b201ae69 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -7563,4 +7563,645 @@ keyframes({ .unwrap() )); } + + #[test] + #[serial] + fn test_styled() { + // Test 1: styled.div`css` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.section` + background: red; + color: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 2: styled("div")`css` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("article")` + background: red; + color: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 3: styled("div")({ bg: "red" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("footer")({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 4: styled.div({ bg: "red" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.aside({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 5: styled(Component)({ bg: "red" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, Text} from '@devup-ui/core' + const StyledComponent = styled(Text)({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, Text} from '@devup-ui/core' + const StyledComponent = styled(Text)` + background: red; + color: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, VStack} from '@devup-ui/core' + const StyledComponent = styled(VStack)({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, VStack} from '@devup-ui/core' + const StyledComponent = styled(VStack)` + background: red; + color: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledComponent = styled(CustomComponent)({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledComponent = styled(CustomComponent)` + background: red; + color: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.aside<{ test: string }>({ bg: "red" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_styled_with_variable() { + // Test 1: styled.div({ bg: "$text" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.div({ bg: "$text", color: "$primary" }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 2: styled("div")({ color: "$primary" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")({ bg: "$text", fontSize: 16 }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 3: styled.div`css` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.div` + background: var(--text); + color: var(--primary); + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 4: styled(Component)({ bg: "$text" }) + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, Box} from '@devup-ui/core' + const StyledComponent = styled(Box)({ bg: "$text", _hover: { bg: "$primary" } }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 5: styled("div")`css` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")` + background-color: var(--text); + padding: 16px; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_styled_with_variable_like_emotion() { + // Test 1: styled.div`css with ${variable}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const color = 'red'; + const StyledDiv = styled.div` + color: ${color}; + background: blue; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 2: styled("div")`css with ${variable}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const primaryColor = 'blue'; + const padding = '16px'; + const StyledDiv = styled("div")` + color: ${primaryColor}; + padding: ${padding}; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const primaryColor = 'blue'; + const padding = '16px'; + const StyledDiv = styled("div")({ bg: primaryColor, padding }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const primaryColor = 'blue'; + const padding = '16px'; + const StyledDiv = styled.div({ bg: primaryColor, padding }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")` + color: ${obj.color}; + padding: ${func()}; + background: ${obj.func()}; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")({ bg: obj.bg, padding: func(), color: obj.color() }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.div({ bg: obj.bg, padding: func(), color: obj.color() }) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_styled_with_variable_like_emotion_props() { + // Test 3: styled.div`css with ${props => props.bg}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled.div` + background: ${props => props.bg}; + color: red; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 4: styled(Component)`css with ${variable}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled, Box} from '@devup-ui/core' + const fontSize = '18px'; + const StyledComponent = styled(Box)` + font-size: ${fontSize}; + color: ${props => props.color || 'black'}; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 5: styled.div`css with multiple ${variables}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const margin = '10px'; + const padding = '20px'; + const StyledDiv = styled.div` + margin: ${margin}; + padding: ${padding}; + background: ${props => props.bg || 'white'}; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + // Test 6: styled.div`css with ${expression}` + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const isActive = true; + const StyledDiv = styled.div` + color: ${isActive ? 'red' : 'blue'}; + opacity: ${isActive ? 1 : 0.5}; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_wrong_styled_with_variable_like_emotion_props() { + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled(null)` + background: ${props => props.bg}; + color: red; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div", "span")` + background: ${props => props.bg}; + color: red; + ` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div", "span").filter(Boolean) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")({ bg: "red" }, {}) + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + + reset_class_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r#"import {styled} from '@devup-ui/core' + const StyledDiv = styled("div")({ bg: "red" }, {})`` + "#, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: false, + import_main_css: false + } + ) + .unwrap() + )); + } } diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 90b0c977..0431174a 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -1,7 +1,7 @@ use crate::ExtractStyleProp; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::utils::get_string_by_property_key; +use crate::utils::{get_string_by_property_key, merge_object_expressions}; use oxc_allocator::CloneIn; use oxc_ast::AstBuilder; use oxc_ast::ast::JSXAttributeName::Identifier; @@ -375,29 +375,6 @@ fn merge_string_expressions<'a>( ) } -/// merge expressions to object expression -fn merge_object_expressions<'a>( - ast_builder: &AstBuilder<'a>, - expressions: &[Expression<'a>], -) -> Option> { - if expressions.is_empty() { - return None; - } - if expressions.len() == 1 { - return Some(expressions[0].clone_in(ast_builder.allocator)); - } - Some(ast_builder.expression_object( - SPAN, - oxc_allocator::Vec::from_iter_in( - expressions.iter().map(|ex| { - ast_builder - .object_property_kind_spread_property(SPAN, ex.clone_in(ast_builder.allocator)) - }), - ast_builder.allocator, - ), - )) -} - pub fn convert_class_name<'a>( ast_builder: &AstBuilder<'a>, class_name: &Expression<'a>, diff --git a/libs/extractor/src/snapshots/extractor__tests__negative_props-3.snap b/libs/extractor/src/snapshots/extractor__tests__negative_props-3.snap index 62cf106a..eaa1e954 100644 --- a/libs/extractor/src/snapshots/extractor__tests__negative_props-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__negative_props-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{ package: \"@devup-ui/core\".to_string(), css_file: None }).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\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())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "z-index", level: 0, - identifier: "-(1 + a)", + identifier: "-(1+a)", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n

;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__negative_props-4.snap b/libs/extractor/src/snapshots/extractor__tests__negative_props-4.snap index 0a00ea49..6b2e3055 100644 --- a/libs/extractor/src/snapshots/extractor__tests__negative_props-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__negative_props-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{ package: \"@devup-ui/core\".to_string(), css_file: None }).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\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())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "z-index", level: 0, - identifier: "-1 * a", + identifier: "-1*a", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap index 497d6548..d0fd2566 100644 --- a/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__props_direct_object_select-3.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{ package: \"@devup-ui/core\".to_string(), css_file: None }).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\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())" --- ToBTreeSet { styles: { @@ -37,11 +37,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "opacity", level: 0, - identifier: "any[\"some\"]", + identifier: "any[`some`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__remove_semicolon-5.snap b/libs/extractor/src/snapshots/extractor__tests__remove_semicolon-5.snap index 1de9248e..14a5abd7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__remove_semicolon-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__remove_semicolon-5.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n\n\"#,\nExtractOption\n{ package: \"@devup-ui/core\".to_string(), css_file: None }).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\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())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "`${color}` + \"\"", + identifier: "`${color}`+``", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled-10.snap b/libs/extractor/src/snapshots/extractor__tests__styled-10.snap new file mode 100644 index 00000000..d017d60e --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled-10.snap @@ -0,0 +1,27 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledComponent = styled(CustomComponent)`\n background: red;\n color: blue;\n `\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledComponent = ({ style, className, ...rest }) => ;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled-11.snap b/libs/extractor/src/snapshots/extractor__tests__styled-11.snap new file mode 100644 index 00000000..8b2f7728 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled-11.snap @@ -0,0 +1,18 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled.aside<{ test: string }>({ bg: \"red\" })\n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = ({ style, className, ...rest }) =>