From 3880636a8c7dbf4c443525a243b19912fee3fb8c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:19:00 +0900 Subject: [PATCH 001/109] fix(extractor): give vanilla-extract and StyleX numbers their library units vanilla-extract styles add px to numbers (in .css.ts files and in style/globalStyle/keyframes calls of ordinary modules) and StyleX adds px/ms to static and dynamic numbers, instead of Devup UI's spacing scale. The unitless property list now matches vanilla-extract's, so strokeWidth, columns, zoom, orphans and similar keep bare numbers everywhere. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/extract_style/constant.rs | 35 ++- .../extractor/extract_style_from_stylex.rs | 35 +-- libs/extractor/src/import_alias_visit.rs | 137 ++++++++- libs/extractor/src/lib.rs | 126 +++++++++ ...__tests__stylex_create_numeric_values.snap | 4 +- ...ests__stylex_create_numeric_with_unit.snap | 2 +- ...xtractor__tests__stylex_dynamic_basic.snap | 2 +- ...xtractor__tests__stylex_dynamic_mixed.snap | 2 +- ...or__tests__stylex_dynamic_multi_param.snap | 2 +- ...__tests__stylex_dynamic_numeric_value.snap | 2 +- ...xtractor__tests__stylex_types_numeric.snap | 2 +- ..._vanilla_extract_all_exports_combined.snap | 2 +- ...r__tests__vanilla_extract_composition.snap | 4 +- ...sts__vanilla_extract_constant_exports.snap | 2 +- ...ct_css_ts_with_vanilla_extract_import.snap | 2 +- ...nilla_extract_global_theme_empty_vars.snap | 2 +- ...illa_extract_global_theme_placeholder.snap | 2 +- ...anilla_extract_global_theme_with_vars.snap | 2 +- ...__vanilla_extract_non_exported_styles.snap | 4 +- ...__vanilla_extract_selector_references.snap | 2 +- ...r__tests__vanilla_extract_selectors-2.snap | 2 +- ...la_extract_style_composition_multiple.snap | 4 +- ...__tests__vanilla_extract_style_css_js.snap | 2 +- ...__tests__vanilla_extract_style_css_ts.snap | 2 +- ...sts__vanilla_extract_style_variants-2.snap | 4 +- ..._vanilla_extract_style_variants_mixed.snap | 4 +- ...illa_extract_style_variants_with_base.snap | 10 +- ...nilla_extract_theme_export_variations.snap | 2 +- ...nilla_extract_theme_without_vars_json.snap | 2 +- ..._tests__vanilla_extract_with_computed.snap | 4 +- ...r__tests__vanilla_extract_with_spread.snap | 4 +- ..._tests__vanilla_extract_with_variable.snap | 2 +- libs/extractor/src/stylex.rs | 260 +++++++++++++++++- libs/extractor/src/utils.rs | 51 ++++ libs/extractor/src/vanilla_extract.rs | 66 ++++- libs/extractor/src/visit.rs | 81 +++++- .../src/__tests__/static-vanilla.test.ts | 77 ------ packages/next-plugin/src/static-vanilla.ts | 194 ------------- 38 files changed, 787 insertions(+), 355 deletions(-) delete mode 100644 packages/next-plugin/src/__tests__/static-vanilla.test.ts delete mode 100644 packages/next-plugin/src/static-vanilla.ts diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs index a1e1821c4..1bb014102 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -1,6 +1,9 @@ use phf::phf_set; -pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { +/// Properties whose numbers are unitless in CSS, so they are kept as written +/// instead of being scaled to `px`. Includes every property vanilla-extract +/// leaves unitless, so numbers from `.css.ts` files keep their meaning. +pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "opacity", "flex", "z-index", @@ -12,6 +15,7 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "flex-grow", "flex-shrink", "order", + "grid-area", "grid-column", "grid-column-start", "grid-column-end", @@ -21,5 +25,32 @@ pub(super) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "animation-iteration-count", "tab-size", "moz-tab-size", - "-webkit-line-clamp" + "-webkit-line-clamp", + "webkit-line-clamp", + "border-image", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "initial-letter", + "max-lines", + "orphans", + "widows", + "zoom", + "fill-opacity", + "flood-opacity", + "mask-border", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "shape-image-threshold", + "stop-opacity", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width" }; diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 568ec9b42..7b469760b 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -3,10 +3,10 @@ use crate::extract_style::extract_dynamic_style::ExtractDynamicStyle; use crate::extract_style::extract_static_style::ExtractStaticStyle; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::stylex::{ - SelectorPart, StylexIncludeRef, decompose_value_conditions, is_first_that_works_call, - is_include_call_static, is_types_call, normalize_stylex_property, + SelectorPart, StylexIncludeRef, decompose_value_conditions, dynamic_number_suffix, + is_first_that_works_call, is_include_call_static, is_types_call, normalize_stylex_property, + stylex_value, }; -use crate::utils::get_string_by_literal_expression; use css::optimize_value::optimize_value; use css::sheet_to_variable_name; use css::style_selector::StyleSelector; @@ -45,12 +45,9 @@ pub fn extract_stylex_declarations(value: &Expression<'_>) -> Vec<(String, Strin let ObjectPropertyKind::ObjectProperty(prop) = prop else { return None; }; - let name = get_str_by_property_key(&prop.key)?; - let value = get_string_by_literal_expression(&prop.value)?; - Some(( - normalize_stylex_property(name.as_ref()), - optimize_value(&value).into_owned(), - )) + let property = normalize_stylex_property(get_str_by_property_key(&prop.key)?.as_ref()); + let value = stylex_value(&property, &prop.value)?; + Some((property, optimize_value(&value).into_owned())) }) .collect() } @@ -110,7 +107,7 @@ pub fn extract_stylex_namespace_styles<'a>( ) -> Vec<( String, Vec>, - Option>, + Option>, Vec, )> { let Expression::ObjectExpression(obj) = expression else { @@ -250,7 +247,7 @@ pub fn extract_stylex_namespace_styles<'a>( } // Phase 1: static string/number values - let css_value = if let Some(s) = get_string_by_literal_expression(&style_prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &style_prop.value) { s } else if matches!(&style_prop.value, Expression::ObjectExpression(_)) { // Phase 2: value-level conditions @@ -272,7 +269,7 @@ pub fn extract_stylex_namespace_styles<'a>( // CSS fallback: output in reverse order (least preferred first, most preferred last). for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(&css_property, arg_expr) { styles.push(raw_static_style(css_property.clone(), &s, None)); } @@ -283,7 +280,7 @@ pub fn extract_stylex_namespace_styles<'a>( // stylex.types.length('100px') → extract inner value '100px' && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - let css_value = if let Some(s) = get_string_by_literal_expression(inner) { + let css_value = if let Some(s) = stylex_value(&css_property, inner) { s } else { continue; // Can't resolve inner value @@ -310,12 +307,16 @@ pub fn extract_stylex_namespace_styles<'a>( } /// Extract styles from a dynamic `StyleX` namespace (arrow function). -/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to CSS variable name. +/// Returns (`styles_for_css`, `css_vars`) where `css_vars` maps `param_index` to a CSS variable +/// name and the unit a number passed for it gets. #[allow(clippy::type_complexity)] fn extract_stylex_dynamic_namespace<'a>( arrow: &oxc_ast::ast::ArrowFunctionExpression<'a>, keyframe_names: &FxHashMap, -) -> Option<(Vec>, Vec<(usize, String)>)> { +) -> Option<( + Vec>, + Vec<(usize, String, &'static str)>, +)> { // 1. Extract parameter names let param_names: Vec = arrow .params @@ -373,7 +374,7 @@ fn extract_stylex_dynamic_namespace<'a>( if let Some(param_idx) = is_dynamic { // Dynamic property: generate CSS variable let var_name = sheet_to_variable_name(&css_property, 0, None); - css_vars.push((param_idx, var_name)); + css_vars.push((param_idx, var_name, dynamic_number_suffix(&css_property))); let param_name = ¶m_names[param_idx]; styles.push(ExtractStyleProp::Static(ExtractStyleValue::Dynamic( ExtractDynamicStyle::new(&css_property, 0, param_name, None), @@ -386,7 +387,7 @@ fn extract_stylex_dynamic_namespace<'a>( styles.push(raw_static_style(css_property, anim_name, None)); continue; } - let css_value = if let Some(s) = get_string_by_literal_expression(&prop.value) { + let css_value = if let Some(s) = stylex_value(&css_property, &prop.value) { s } else { continue; diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 259bf357f..c039151de 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,83 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; -use crate::utils::is_vanilla_extract_file; +use crate::utils::{ + get_str_by_property_key, is_unitless_key, is_vanilla_extract_file, js_number_literal, +}; use oxc_allocator::Allocator; -use oxc_ast::ast::{ImportDeclarationSpecifier, ModuleExportName}; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, ImportDeclarationSpecifier, ModuleExportName, + ObjectPropertyKind, Statement, +}; +use oxc_ast_visit::{Visit, walk::walk_call_expression}; use oxc_parser::Parser; -use oxc_span::SourceType; +use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; +/// Numbers in vanilla-extract `style()` / `globalStyle()` / `keyframes()` rules outside a +/// stylesheet. Those calls become Devup UI's `css()` / `globalCss()` / `keyframes()`, which read a +/// number as its spacing scale, so it is rewritten to the `px` string +/// vanilla-extract makes of it. +struct VanillaNumbers<'n> { + /// Local name of each call and the index of its rules argument + calls: Vec<(&'n str, usize)>, + replacements: Vec<(usize, usize, String)>, +} + +impl VanillaNumbers<'_> { + fn pixelify(&mut self, rules: &Expression) { + match rules { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify(element); + } + } + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = get_str_by_property_key(&property.key) + { + if matches!(property.value, Expression::ObjectExpression(_)) { + if key != "vars" { + self.pixelify(&property.value); + } + } else if let Some(number) = js_number_literal(&property.value) + && number != 0.0 + && !is_unitless_key(&key) + { + let span = property.value.span(); + self.replacements.push(( + span.start as usize, + span.end as usize, + format!("\"{number}px\""), + )); + } + } + } + } + _ => {} + } + } +} + +impl<'a> Visit<'a> for VanillaNumbers<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Expression::Identifier(callee) = &call.callee + && let Some(&(_, index)) = self + .calls + .iter() + .find(|(name, _)| *name == callee.name.as_str()) + && let Some(rules) = call.arguments.get(index).and_then(Argument::as_expression) + { + self.pixelify(rules); + } + walk_call_expression(self, call); + } +} + /// Map an aliased package's export onto the `@devup-ui/react` export that implements the /// same behaviour, so the extractor consumes the call and drops the import entirely — no /// dependency on either package survives. @@ -87,9 +156,13 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); + let mut numbers = VanillaNumbers { + calls: Vec::new(), + replacements: Vec::new(), + }; for stmt in &program.body { - if let oxc_ast::ast::Statement::ImportDeclaration(import_decl) = stmt { + if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); if let Some(alias) = import_aliases.get(source_value) { @@ -97,9 +170,26 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); + if source_value == "@vanilla-extract/css" && !redirect_every_name { + for specifier in import_decl.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { + let rules_index = match imported_name(&spec.imported).as_ref() { + "style" | "keyframes" => 0, + "globalStyle" => 1, + _ => continue, + }; + numbers.calls.push((spec.local.name.as_str(), rules_index)); + } + } + } } } } + if !numbers.calls.is_empty() { + numbers.visit_program(&program); + transformations.append(&mut numbers.replacements); + transformations.sort_unstable_by_key(|(start, ..)| *start); + } // Apply transformations in reverse order to preserve positions if transformations.is_empty() { @@ -501,6 +591,45 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { + let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' +export const a = s({ padding: 8, top: -2, left: (1.5), width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: 4 } } }) +export const b = s([a, { right: 3 }], 'debug') +globalStyle('body', { margin: 2 }) +keyframes({ from: { width: 10, opacity: 0 }, '50%': { width: 20 } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"; + assert_eq!( + transform_import_aliases( + code, + "test.tsx", + "@devup-ui/react", + &vanilla_extract_alias() + ), + r#"import { css as s, globalCss as globalStyle, keyframes } from '@devup-ui/react'; import { styleVariants } from '@vanilla-extract/css'; +export const a = s({ padding: "8px", top: "-2px", left: "1.5px", width: 0, lineHeight: 1.5, vars: { '--x': 4 }, [key]: 5, ...rest, selectors: { '&:hover': { margin: "4px" } } }) +export const b = s([a, { right: "3px" }], 'debug') +globalStyle('body', { margin: "2px" }) +keyframes({ from: { width: "10px", opacity: 0 }, '50%': { width: "20px" } }) +styleVariants({ small: { padding: 1 } }) +other({ padding: 8 }) +s()"# + ); + let stylesheet = + "import { style } from '@vanilla-extract/css'\nexport const a = style({ padding: 8 })"; + assert!( + transform_import_aliases( + stylesheet, + "a.css.ts", + "@devup-ui/react", + &vanilla_extract_alias() + ) + .ends_with("style({ padding: 8 })") + ); + } + #[test] fn test_multiple_imports_same_file() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9b3759ae4..b287c1a8f 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14746,6 +14746,132 @@ const styles = stylex.create({ )); } + fn static_values(output: &ExtractOutput) -> BTreeSet<(String, String)> { + output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(st) => { + Some((st.property().to_string(), st.value().to_string())) + } + ExtractStyleValue::Keyframes(keyframes) => keyframes + .keyframes + .values() + .flatten() + .next() + .map(|st| (st.property().to_string(), st.value().to_string())), + _ => None, + }) + .collect() + } + + #[test] + #[serial] + fn test_vanilla_extract_numbers_follow_vanilla_extract_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "numbers.css.ts", + r"import { style, globalStyle, keyframes, styleVariants } from '@devup-ui/react' +export const box = style({ + fontSize: 16, + top: -2, + width: 0, + lineHeight: 1.5, + zIndex: 2, + columnCount: 3, + strokeWidth: 2, + aspectRatio: 1.5, + selectors: { '&:hover': { marginTop: 4 } }, + '@media': { print: { left: 1.5 } }, +}) +export const spin = keyframes({ to: { height: 10 } }) +export const size = styleVariants({ small: { maxWidth: 20 } }) +globalStyle('body', { margin: 2 }) +", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("top", "-2px"), + ("width", "0"), + ("line-height", "1.5"), + ("z-index", "2"), + ("column-count", "3"), + ("stroke-width", "2"), + ("aspect-ratio", "1.5"), + ("margin-top", "4px"), + ("left", "1.5px"), + ("height", "10px"), + ("max-width", "20px"), + ("margin", "2px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + } + + #[test] + #[serial] + fn test_stylex_numbers_follow_stylex_units() { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + r"import stylex from '@stylexjs/stylex'; +const styles = stylex.create({ + base: { + fontSize: 16, + marginTop: -8, + width: -0, + height: '16', + lineHeight: 1.5, + transitionDuration: 300, + '--size': 4, + left: 1.33333, + top: { default: 2, ':hover': 4 }, + right: stylex.firstThatWorks(6, 'auto'), + }, + dynamic: (opacity, delay) => ({ opacity, transitionDelay: delay, bottom: 3 }), +}); +const result = stylex.props(styles.dynamic(o, d));", + ExtractOption::default(), + ) + .unwrap(); + let values = static_values(&output); + for (property, value) in [ + ("font-size", "16px"), + ("margin-top", "-8px"), + ("width", "0"), + ("height", "16"), + ("line-height", "1.5"), + ("transition-duration", "300ms"), + ("--size", "4"), + ("left", "1.3333px"), + ("top", "2px"), + ("top", "4px"), + ("right", "6px"), + ("bottom", "3px"), + ] { + assert!( + values.contains(&(property.to_string(), value.to_string())), + "{property}: {value} not in {values:?}" + ); + } + assert!(output.code.contains(r#""--a": o"#), "{}", output.code); + assert!( + output + .code + .contains(r#"((v) => typeof v === "number" ? v + "ms" : v)(d)"#), + "{}", + output.code + ); + } + #[test] #[serial] fn test_stylex_create_numeric_values() { diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap index d8a33c39a..af003b424 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_values.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "8", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap index 4478cd7c8..5a952aed5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_create_numeric_with_unit.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap index 0e64214a1..2432d59ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_basic.snap @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b\" };\nconst result = {\n\tclassName: \"b\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap index 255d23b9e..d4c3552f1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_mixed.snap @@ -24,5 +24,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": h }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"b c\" };\nconst result = {\n\tclassName: \"b c\",\n\tstyle: { \"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(h) }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap index af63ba3ef..81725cdab 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_multi_param.snap @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": myH,\n\t\t\"--b\": myW\n\t}\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport stylex from \"@stylexjs/stylex\";\nconst styles = { \"bar\": \"c d\" };\nconst result = {\n\tclassName: \"c d\",\n\tstyle: {\n\t\t\"--a\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myH),\n\t\t\"--b\": ((v) => typeof v === \"number\" ? v + \"px\" : v)(myW)\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap index 21272d366..d2c86cbbf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_dynamic_numeric_value.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap index 28c1eda92..b04a3ed7c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap +++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_numeric.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "16", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 9f4b29a89..29976b6ec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -71,7 +71,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap index 796a2aade..307ac06da 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_composition.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap index 239a9d9a2..611abb463 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap index ad214187d..cbaf24c7f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_css_ts_with_vanilla_extract_import.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "128px", + value: "32px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap index 1c1914d52..754ab6170 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_empty_vars.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap index e25bfd199..26b2f670b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap index f6dafe73f..09eedf6be 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap index 15a6ef98d..b37bcbadb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap index e7f52c8e8..39460fa16 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap index c6d642b08..65fd54dcf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap @@ -31,7 +31,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin-top", - value: "32px", + value: "8px", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap index 6f0442004..143f62597 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_composition_multiple.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap index 2fc830773..019824caf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_js.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap index 9ae96afb3..f3d821654 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_css_ts.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap index a546eb220..84a2b9346 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -57,7 +57,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap index 204774f28..27943e415 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -67,7 +67,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap index 31b0af989..1d2367b8e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "48px", + value: "12px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, @@ -37,7 +37,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "80px", + value: "20px", level: 0, selector: None, style_order: None, @@ -47,7 +47,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap index 32beafe5d..33392c10c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap index 0379d0c0d..b718003f7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap @@ -24,7 +24,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap index ce2e9b896..e4a5505d8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_computed.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap index 474649ffd..cc83fec00 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_spread.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "margin", - value: "16px", + value: "4px", level: 0, selector: None, style_order: None, @@ -27,7 +27,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "32px", + value: "8px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap index 5a9821856..bb689c12b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_variable.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "64px", + value: "16px", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs index 10cddf7c3..134360fdc 100644 --- a/libs/extractor/src/stylex.rs +++ b/libs/extractor/src/stylex.rs @@ -1,7 +1,11 @@ +use std::borrow::Cow; + use css::style_selector::{AtRuleKind, StyleSelector}; use oxc_ast::ast::{Expression, ObjectPropertyKind}; -use crate::utils::{get_string_by_literal_expression, get_string_by_property_key}; +use crate::utils::{ + get_string_by_literal_expression, get_string_by_property_key, js_number_literal, +}; /// Which `StyleX` function a named import refers to #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,6 +117,248 @@ pub fn normalize_stylex_property(name: &str) -> String { css::utils::to_kebab_case(name).into_owned() } +/// Properties `StyleX` leaves unitless when given a number. +static UNITLESS_NUMBER_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "webkit-line-clamp", + "animation-iteration-count", + "aspect-ratio", + "border-image-outset", + "border-image-slice", + "border-image-width", + "counter-set", + "counter-reset", + "column-count", + "flex", + "flex-grow", + "flex-shrink", + "flex-order", + "grid-row", + "grid-row-start", + "grid-row-end", + "grid-column", + "grid-column-start", + "grid-column-end", + "grid-area", + "font-size-adjust", + "font-weight", + "hyphenate-limit-chars", + "line-clamp", + "line-height", + "mask-border-outset", + "mask-border-slice", + "mask-border-width", + "opacity", + "order", + "orphans", + "tab-size", + "widows", + "z-index", + "fill-opacity", + "flood-opacity", + "rotate", + "scale", + "shape-image-threshold", + "stop-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "math-depth", + "zoom", +}; + +/// Properties whose numbers `StyleX` reads as milliseconds. +static TIME_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "animation-delay", + "animation-duration", + "transition-delay", + "transition-duration", + "voice-duration", +}; + +/// Properties whose dynamic values `StyleX` gives a unit when they are numbers. +static LENGTH_PROPERTIES: phf::Set<&'static str> = phf::phf_set! { + "background-position-x", + "background-position-y", + "block-size", + "border-block-end-width", + "border-block-start-width", + "border-block-width", + "border-vertical-width", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-width", + "border-end-end-radius", + "border-end-start-radius", + "border-inline-end-width", + "border-end-width", + "border-inline-start-width", + "border-start-width", + "border-inline-width", + "border-horizontal-width", + "border-left-width", + "border-right-width", + "border-spacing", + "border-start-end-radius", + "border-start-start-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-top-width", + "bottom", + "column-gap", + "column-rule-width", + "column-width", + "contain-intrinsic-block-size", + "contain-intrinsic-height", + "contain-intrinsic-inline-size", + "contain-intrinsic-width", + "flex-basis", + "font-size", + "font-smooth", + "height", + "inline-size", + "inset-block-end", + "inset-block-start", + "inset-inline-end", + "inset-inline-start", + "left", + "letter-spacing", + "margin-block-end", + "margin-block-start", + "margin-bottom", + "margin-inline-end", + "margin-end", + "margin-inline-start", + "margin-start", + "margin-left", + "margin-right", + "margin-top", + "max-block-size", + "max-height", + "max-inline-size", + "max-width", + "min-block-size", + "min-height", + "min-inline-size", + "min-width", + "offset-distance", + "outline-offset", + "outline-width", + "overflow-clip-margin", + "padding-block-end", + "padding-block-start", + "padding-bottom", + "padding-inline-end", + "padding-end", + "padding-inline-start", + "padding-start", + "padding-left", + "padding-right", + "padding-top", + "perspective", + "right", + "row-gap", + "scroll-margin-block-end", + "scroll-margin-block-start", + "scroll-margin-bottom", + "scroll-margin-inline-end", + "scroll-margin-inline-start", + "scroll-margin-left", + "scroll-margin-right", + "scroll-margin-top", + "scroll-padding-block-end", + "scroll-padding-block-start", + "scroll-padding-bottom", + "scroll-padding-inline-end", + "scroll-padding-inline-start", + "scroll-padding-left", + "scroll-padding-right", + "scroll-padding-top", + "scroll-snap-margin-bottom", + "scroll-snap-margin-left", + "scroll-snap-margin-right", + "scroll-snap-margin-top", + "shape-margin", + "tab-size", + "text-decoration-thickness", + "text-indent", + "text-underline-offset", + "top", + "transform-origin", + "translate", + "vertical-align", + "width", + "word-spacing", + "border", + "border-block", + "border-block-end", + "border-block-start", + "border-bottom", + "border-left", + "border-radius", + "border-right", + "border-top", + "border-width", + "column-rule", + "contain-intrinsic-size", + "gap", + "inset", + "inset-block", + "inset-inline", + "margin", + "margin-block", + "margin-vertical", + "margin-inline", + "margin-horizontal", + "offset", + "outline", + "padding", + "padding-block", + "padding-vertical", + "padding-inline", + "padding-horizontal", + "scroll-margin", + "scroll-margin-block", + "scroll-margin-inline", + "scroll-padding", + "scroll-padding-block", + "scroll-padding-inline", + "scroll-snap-margin", +}; + +/// The unit `StyleX` appends to a number on `property`. +fn number_suffix(property: &str) -> &'static str { + if UNITLESS_NUMBER_PROPERTIES.contains(property) || property.starts_with("--") { + "" + } else if TIME_PROPERTIES.contains(property) { + "ms" + } else { + "px" + } +} + +/// The unit a dynamic value on `property` gets when it is a number at runtime. +pub fn dynamic_number_suffix(property: &str) -> &'static str { + if TIME_PROPERTIES.contains(property) || LENGTH_PROPERTIES.contains(property) { + number_suffix(property) + } else { + "" + } +} + +/// A literal `StyleX` value as CSS text: a number gets the unit `StyleX` gives it. +pub fn stylex_value<'a>(property: &str, value: &Expression<'a>) -> Option> { + js_number_literal(value).map_or_else( + || get_string_by_literal_expression(value), + |number| { + // `+ 0.0` turns `-0` into `0`, as JS prints it. + let rounded = (number * 10_000.0).round() / 10_000.0 + 0.0; + Some(Cow::Owned(format!("{rounded}{}", number_suffix(property)))) + }, + ) +} + /// Intermediate selector parts collected during recursion. #[derive(Debug, Clone)] pub enum SelectorPart { @@ -136,8 +382,8 @@ pub struct DecomposedStyle { pub struct StylexDynamicInfo { /// Combined class name string for all properties (static + dynamic) pub class_name: String, - /// Maps (`param_index`, `css_variable_name`) for each dynamic property - pub css_vars: Vec<(usize, String)>, + /// (`param_index`, `css_variable_name`, unit for a number) for each dynamic property + pub css_vars: Vec<(usize, String, &'static str)>, } /// A `StyleX` namespace entry — either static or dynamic (arrow function) @@ -163,8 +409,8 @@ pub fn decompose_value_conditions( value: &Expression, parent_selectors: &[SelectorPart], ) -> Vec { - // String literal → leaf - if let Some(s) = get_string_by_literal_expression(value) { + // String or number literal → leaf + if let Some(s) = stylex_value(css_property, value) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); @@ -184,7 +430,7 @@ pub fn decompose_value_conditions( let mut results = vec![]; for arg in call.arguments.iter().rev() { if let Some(arg_expr) = arg.as_expression() - && let Some(s) = get_string_by_literal_expression(arg_expr) + && let Some(s) = stylex_value(css_property, arg_expr) { results.extend(decomposed_leaf( css_property, @@ -201,7 +447,7 @@ pub fn decompose_value_conditions( && is_types_call(&call.callee) && let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression()) { - if let Some(s) = get_string_by_literal_expression(inner) { + if let Some(s) = stylex_value(css_property, inner) { return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors) .into_iter() .collect(); diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index d976d7ed6..3e2c88082 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -1,5 +1,7 @@ use std::borrow::Cow; +use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES; +use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ ast::{ @@ -25,6 +27,31 @@ pub(super) fn is_vanilla_extract_file(filename: &str) -> bool { filename.ends_with(".css.ts") || filename.ends_with(".css.js") } +/// Whether vanilla-extract leaves a number on `key` without a unit: unitless +/// properties, custom properties, and array indices. +pub(super) fn is_unitless_key(key: &str) -> bool { + key.starts_with("--") + || key.starts_with("var(") + || key.bytes().all(|byte| byte.is_ascii_digit()) + || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) +} + +/// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string. +pub(super) fn js_number_literal(value: &Expression) -> Option { + match value { + Expression::NumericLiteral(number) => Some(number.value), + Expression::ParenthesizedExpression(inner) => js_number_literal(&inner.expression), + Expression::UnaryExpression(unary) => { + js_number_literal(&unary.argument).and_then(|number| match unary.operator { + UnaryOperator::UnaryNegation => Some(-number), + UnaryOperator::UnaryPlus => Some(number), + _ => None, + }) + } + _ => None, + } +} + /// Strip the wrappers that exist only in the source text: TypeScript's `as` / /// `satisfies` / `!` / explicit type arguments, plus redundant parentheses. Every /// one is erased before the code runs, so extraction must see through them — @@ -496,6 +523,30 @@ mod tests { assert!(matches!(convert_value("4"), Cow::Owned(_))); } + #[test] + fn test_js_number_literal() { + let allocator = Allocator::default(); + for (source, expected) in [ + ("8", Some(8.0)), + ("(8)", Some(8.0)), + ("+8", Some(8.0)), + ("-8", Some(-8.0)), + ("-(1.5)", Some(-1.5)), + ("!8", None), + ("'8'", None), + ("-x", None), + ] { + let expression = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .unwrap(); + assert_eq!(js_number_literal(&expression), expected, "{source}"); + } + assert!(is_unitless_key("lineHeight")); + assert!(is_unitless_key("--gap")); + assert!(is_unitless_key("0")); + assert!(!is_unitless_key("padding")); + } + #[test] fn test_get_number_by_literal_expression() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 276369a98..4d8732b73 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,8 +3,10 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. +use crate::utils::is_unitless_key; use boa_engine::{ - Context, JsArgs, JsValue, NativeFunction, Source, js_string, object::ObjectInitializer, + Context, JsArgs, JsResult, JsValue, NativeFunction, Source, js_string, + object::{FunctionObjectBuilder, ObjectInitializer}, property::Attribute, }; use css::file_map::get_file_num_by_filename; @@ -367,6 +369,38 @@ fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { } } +/// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, +/// so they are not read as Devup UI's spacing scale. +fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let key = args.get_or_undefined(0).to_string(context)?; + let value = args.get_or_undefined(1); + Ok(match value.as_number() { + Some(number) if number != 0.0 && !is_unitless_key(&key.to_std_string_escaped()) => { + JsValue::from(js_string!(format!("{number}px"))) + } + _ => value.clone(), + }) +} + +/// Serialize a style rule object with vanilla-extract's number units. +fn style_to_json(value: &JsValue, context: &mut Context) -> String { + let realm = context.realm().clone(); + let replacer = + FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); + let json = context.intrinsics().objects().json(); + if let Ok(stringify) = json.get(js_string!("stringify"), context) + && let Some(callable) = stringify.as_callable() + && let Ok(result) = callable.call( + &JsValue::undefined(), + &[value.clone(), replacer.into()], + context, + ) + && let Some(s) = result.as_string() + { + return s.to_std_string_escaped(); + } + js_value_to_json(value, context) +} /// Execute vanilla-extract style file and collect styles pub fn execute_vanilla_extract( code: &str, @@ -825,7 +859,7 @@ fn register_vanilla_extract_apis( base_classes.push(base_str.to_std_string_escaped()); } else if elem.is_object() { // It's a style object - merge it - let elem_json = js_value_to_json(&elem, ctx); + let elem_json = style_to_json(&elem, ctx); // Strip outer braces and merge let inner = elem_json .trim() @@ -846,7 +880,7 @@ fn register_vanilla_extract_apis( (merged_styles, base_classes) } else { // No length property, just a style object - (js_value_to_json(style_obj, ctx), SmallVec::new()) + (style_to_json(style_obj, ctx), SmallVec::new()) }; collector_style.borrow_mut().styles.styles.insert( id.clone(), @@ -870,7 +904,7 @@ fn register_vanilla_extract_apis( .to_string(ctx)? .to_std_string_escaped(); let style_obj = args.get_or_undefined(1); - let json = js_value_to_json(style_obj, ctx); + let json = style_to_json(style_obj, ctx); collector_global .borrow_mut() @@ -887,7 +921,7 @@ fn register_vanilla_extract_apis( let keyframes_fn = unsafe { NativeFunction::from_closure(move |_this, args, ctx| { let keyframes_obj = args.get_or_undefined(0); - let json = js_value_to_json(keyframes_obj, ctx); + let json = style_to_json(keyframes_obj, ctx); let id = next_style_id(&collector_keyframes); collector_keyframes.borrow_mut().styles.keyframes.insert( @@ -1627,7 +1661,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant if base_class.starts_with("__style_") || !base_class.contains('{') { // Get the style object (second element) if let Ok(style_obj) = obj.get(1, context) { - let json = js_value_to_json(&style_obj, context); + let json = style_to_json(&style_obj, context); return StyleVariant { base: Some(base_class), styles_json: json, @@ -1641,7 +1675,7 @@ fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant // Not an array or not composition - treat as plain style object StyleVariant { base: None, - styles_json: js_value_to_json(value, context), + styles_json: style_to_json(value, context), } } @@ -3439,6 +3473,24 @@ export const box = style({ padding: 8 })"; assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); } + #[test] + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 } })", + )) + .unwrap(); + assert_eq!( + super::style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"}}"# + ); + assert_eq!( + super::style_to_json(&JsValue::undefined(), &mut context), + "undefined" + ); + } + #[test] fn test_style_with_non_object_argument() { // Test style() with primitive value (covers line 749) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 8850391ec..f6cfc066f 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -30,11 +30,12 @@ use oxc_ast::ast::ImportDeclarationSpecifier::{self, ImportSpecifier}; use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Argument, BindingPattern, CallExpression, ChainElement, ComputedMemberExpression, Expression, - ExpressionStatement, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, - ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, - JSXElement, JSXElementName, JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, - Program, PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + Argument, BinaryOperator, BindingPattern, CallExpression, ChainElement, + ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, + FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, + JSXAttributeItem, JSXAttributeValue, JSXChild, JSXClosingFragment, JSXElement, JSXElementName, + JSXExpressionContainer, JSXOpeningFragment, ObjectPropertyKind, Program, PropertyKey, + PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; @@ -305,6 +306,71 @@ impl<'a> DevupVisitor<'a> { (class_exprs, style_props) } + /// `((v) => typeof v === "number" ? v + unit : v)(value)`: how `StyleX` gives a + /// dynamic number its unit without evaluating `value` twice. + fn with_number_unit(&self, value: Expression<'a>, unit: &'static str) -> Expression<'a> { + if unit.is_empty() { + return value; + } + let param = || Expression::new_identifier(SPAN, "v", &self.ast); + let body = Expression::new_conditional_expression( + SPAN, + Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, param(), &self.ast), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, &self.ast), + &self.ast, + ), + Expression::new_binary_expression( + SPAN, + param(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, unit, None, &self.ast), + &self.ast, + ), + param(), + &self.ast, + ); + let mut params = oxc_allocator::Vec::new_in(&self.ast); + params.push(FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(&self.ast), + BindingPattern::new_binding_identifier(SPAN, "v", &self.ast), + None, + None, + false, + None, + false, + false, + &self.ast, + )); + let arrow = Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + params, + None::>>, + &self.ast, + ), + None::>>, + body.into(), + &self.ast, + ); + let mut arguments = oxc_allocator::Vec::new_in(&self.ast); + arguments.push(Argument::from(value)); + Expression::new_call_expression( + SPAN, + Expression::new_parenthesized_expression(SPAN, arrow, &self.ast), + None::>>, + arguments, + false, + &self.ast, + ) + } /// Resolve a dynamic namespace call like `styles.bar(h)` to (className, `style_props`). fn resolve_stylex_dynamic_call( &self, @@ -324,11 +390,12 @@ impl<'a> DevupVisitor<'a> { ); let mut props = Vec::with_capacity(info.css_vars.len()); - for (param_idx, var_name) in &info.css_vars { + for (param_idx, var_name, unit) in &info.css_vars { if let Some(arg) = call.arguments.get(*param_idx) && let Some(arg_expr) = arg.as_expression() { - let arg_expr = arg_expr.clone_in(self.ast.allocator()); + let arg_expr = + self.with_number_unit(arg_expr.clone_in(self.ast.allocator()), unit); props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/packages/next-plugin/src/__tests__/static-vanilla.test.ts b/packages/next-plugin/src/__tests__/static-vanilla.test.ts deleted file mode 100644 index 16da2ad98..000000000 --- a/packages/next-plugin/src/__tests__/static-vanilla.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { transformStaticVanillaExtract } from '../static-vanilla' - -describe('transformStaticVanillaExtract', () => { - it('converts static style modules without evaluating JavaScript', () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red', padding: [4, null, 8] }) -export const hover = style({ selectors: { '&:hover': { opacity: 0.5 } } })` - - expect( - transformStaticVanillaExtract( - 'src/styles.css.ts', - source, - '@devup-ui/react', - ), - ).toBe(`import { css } from '@devup-ui/react' -export const box = css({ color: 'red', padding: [4, null, 8] }) -export const hover = css({ selectors: { '&:hover': { opacity: 0.5 } } })`) - }) - - it('rejects executable values and extra module statements', () => { - const dynamic = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const spread = `import { style } from '@vanilla-extract/css' -export const box = style({ ...base, color: 'red' })` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - dynamic, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.css.ts', spread, '@devup-ui/react'), - ).toBeUndefined() - expect( - transformStaticVanillaExtract('styles.ts', dynamic, '@devup-ui/react'), - ).toBeUndefined() - }) - - it('preserves comments and aliases in the static subset', () => { - const source = `/* import */ import { style as makeStyle } from '@vanilla-extract/css' -export const box = makeStyle({ - // responsive values remain source text - padding: [4, null, 8], -})` - - expect( - transformStaticVanillaExtract('styles.css.js', source, 'custom-package'), - ).toContain('export const box = css({') - }) - - it('rejects unsupported imports and malformed trailing comments', () => { - const multipleImports = `import { style, globalStyle } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const unterminatedComment = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' }) /*` - - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - multipleImports, - '@devup-ui/react', - ), - ).toBeUndefined() - expect( - transformStaticVanillaExtract( - 'styles.css.ts', - unterminatedComment, - '@devup-ui/react', - ), - ).toBeUndefined() - }) -}) diff --git a/packages/next-plugin/src/static-vanilla.ts b/packages/next-plugin/src/static-vanilla.ts deleted file mode 100644 index 3bdd237c4..000000000 --- a/packages/next-plugin/src/static-vanilla.ts +++ /dev/null @@ -1,194 +0,0 @@ -function isIdentifierChar(char: string | undefined): boolean { - return char !== undefined && /[$\w]/.test(char) -} - -class StaticVanillaParser { - private index = 0 - private valid = true - - constructor( - private readonly source: string, - private readonly packageName: string, - ) {} - - parse(): string | undefined { - if (!this.word('import') || !this.char('{')) return undefined - const imported = this.identifier() - if (imported !== 'style') return undefined - let local = imported - if (this.word('as')) { - const alias = this.identifier() - if (alias === undefined) return undefined - local = alias - } - if ( - !this.char('}') || - !this.word('from') || - this.string() !== '@vanilla-extract/css' - ) { - return undefined - } - this.char(';') - - const declarations: string[] = [] - while (!this.done()) { - if (!this.word('export') || !this.word('const')) return undefined - const name = this.identifier() - if (name === undefined || !this.char('=') || !this.word(local)) { - return undefined - } - if (!this.char('(')) return undefined - this.skipTrivia() - const valueStart = this.index - if (!this.staticValue()) return undefined - const value = this.source.slice(valueStart, this.index) - if (!this.char(')')) return undefined - this.char(';') - declarations.push(`export const ${name} = css(${value})`) - } - - if (declarations.length === 0) return undefined - return `import { css } from '${this.packageName}'\n${declarations.join('\n')}` - } - - private staticValue(): boolean { - this.skipTrivia() - const char = this.source[this.index] - if (char === '{') return this.object() - if (char === '[') return this.array() - if (char === '"' || char === "'") return this.string() !== undefined - if (char === '+' || char === '-') this.index++ - const number = this.source - .slice(this.index) - .match(/^(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i) - if (number) { - this.index += number[0].length - return true - } - return this.word('true') || this.word('false') || this.word('null') - } - - private object(): boolean { - if (!this.char('{')) return false - if (this.char('}')) return true - while (true) { - this.skipTrivia() - const char = this.source[this.index] - const key = - char === '"' || char === "'" - ? this.string() - : (this.identifier() ?? this.numberKey()) - if (key === undefined || !this.char(':') || !this.staticValue()) { - return false - } - if (this.char('}')) return true - if (!this.char(',')) return false - if (this.char('}')) return true - } - } - - private array(): boolean { - if (!this.char('[')) return false - if (this.char(']')) return true - while (true) { - if (!this.staticValue()) return false - if (this.char(']')) return true - if (!this.char(',')) return false - if (this.char(']')) return true - } - } - - private numberKey(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^\d+(?:\.\d+)?/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private identifier(): string | undefined { - this.skipTrivia() - const match = this.source.slice(this.index).match(/^[$A-Z_a-z][$\w]*/) - if (!match) return undefined - this.index += match[0].length - return match[0] - } - - private string(): string | undefined { - this.skipTrivia() - const quote = this.source[this.index] - if (quote !== '"' && quote !== "'") return undefined - const start = ++this.index - while (this.index < this.source.length) { - const char = this.source[this.index++] - if (char === '\\') { - this.index++ - } else if (char === quote) { - return this.source.slice(start, this.index - 1) - } else if (char === '\n' || char === '\r') { - return undefined - } - } - return undefined - } - - private word(value: string): boolean { - this.skipTrivia() - if ( - !this.source.startsWith(value, this.index) || - isIdentifierChar(this.source[this.index - 1]) || - isIdentifierChar(this.source[this.index + value.length]) - ) { - return false - } - this.index += value.length - return true - } - - private char(value: string): boolean { - this.skipTrivia() - if (this.source[this.index] !== value) return false - this.index++ - return true - } - - private done(): boolean { - this.skipTrivia() - return this.valid && this.index === this.source.length - } - - private skipTrivia(): void { - while (this.index < this.source.length) { - if (/\s/.test(this.source[this.index])) { - this.index++ - } else if (this.source.startsWith('//', this.index)) { - const newline = this.source.indexOf('\n', this.index + 2) - this.index = newline === -1 ? this.source.length : newline + 1 - } else if (this.source.startsWith('/*', this.index)) { - const end = this.source.indexOf('*/', this.index + 2) - if (end === -1) { - this.valid = false - this.index = this.source.length - } else { - this.index = end + 2 - } - } else { - break - } - } - } -} - -/** - * Convert the deliberately small, statically provable subset of vanilla - * `style()` modules into Devup `css()` calls. Any executable expression or - * additional statement returns undefined and keeps the existing Boa path. - */ -export function transformStaticVanillaExtract( - filename: string, - source: string, - packageName: string, -): string | undefined { - if (!/\.css\.(?:ts|js)$/.test(filename)) return undefined - return new StaticVanillaParser(source, packageName).parse() -} From 8159ad206e4ad4f0b51c1a7a1ed68c8e371b209f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:20:41 +0900 Subject: [PATCH 002/109] fix(next-plugin): run every vanilla-extract stylesheet on the full engine Remove the static .css.ts fast path so development and production builds evaluate stylesheets the same way and produce the same class names and CSS. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/__tests__/coordinator.test.ts | 47 ---------------- .../next-plugin/src/__tests__/plugin.test.ts | 55 +------------------ packages/next-plugin/src/coordinator.ts | 8 +-- packages/next-plugin/src/plugin.ts | 38 ++----------- 4 files changed, 6 insertions(+), 142 deletions(-) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 6a23d2555..62dd85c80 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -215,53 +215,6 @@ describe('coordinator', () => { coordinator.close() }) - it('uses the static vanilla transform on a production cache miss', async () => { - const source = `import { style } from '@vanilla-extract/css' -export const box = style({ color: 'red' })` - const extractOutput = { - code: 'transformed code', - map: undefined, - cssFile: undefined, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } - codeExtractWithoutSourceMapSpy.mockReturnValue(extractOutput) - const coordinator = startCoordinator( - makeOptions({ sourceMap: false, staticVanillaExtract: true }), - ) - await new Promise((resolve) => setTimeout(resolve, 100)) - const port = parseInt( - (writeFileSyncSpy.mock.calls[0] as [string, string])[1], - ) - - const res = await httpRequest( - port, - 'POST', - '/extract', - JSON.stringify({ - filename: 'src/styles.css.ts', - code: source, - resourcePath: join(process.cwd(), 'src', 'styles.css.ts'), - }), - ) - - expect(res.status).toBe(200) - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - './../.tmp-coordinator-test/css', - false, - false, - true, - {}, - ) - expect(extractOutput.free).toHaveBeenCalledTimes(1) - coordinator.close() - }) - it('reuses byte-identical singleCss prewarm output', async () => { const source = 'const x = ' const options = makeOptions({ diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index c6ffe926a..a79904cea 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -193,13 +193,6 @@ describe('DevupUINextPlugin', () => { expect( selectWasmVariant({ files: ['src/theme.css.js'] } as StaticImportGraph), ).toBe('full') - expect( - selectWasmVariant( - { files: ['src/theme.css.ts'] } as StaticImportGraph, - ['src/theme.css.ts'], - true, - ), - ).toBe('lite') expect( selectWasmVariant({ files: ['src/page.tsx'] } as StaticImportGraph, [ 'node_modules/design-system/theme.css.ts', @@ -675,10 +668,9 @@ describe('DevupUINextPlugin', () => { prewarmedFiles: expect.any(Array), prewarmedOutputs: expect.any(Map), sourceMap: false, - staticVanillaExtract: false, }) }) - it('uses the lite extractor for a provably static vanilla style module', () => { + it('extracts vanilla-extract modules as written with the full engine', () => { setNodeEnv('production') process.env.TURBOPACK = '1' const filename = resolve('src/styles.css.ts') @@ -696,47 +688,6 @@ export const box = style({ color: 'red' })` String(path).endsWith('styles.css.ts') ? source : '{}', ) - try { - DevupUI({}) - - expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledWith( - 'src/styles.css.ts', - `import { css } from '@devup-ui/react' -export const box = css({ color: 'red' })`, - '@devup-ui/react', - expect.any(String), - false, - false, - true, - expect.anything(), - ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: true }), - ) - } finally { - graphSpy.mockRestore() - compiledSpy.mockRestore() - } - }) - it('keeps the full extractor for dynamic vanilla style modules', () => { - setNodeEnv('production') - process.env.TURBOPACK = '1' - const filename = resolve('src/styles.css.ts') - const source = `import { style } from '@vanilla-extract/css' -const color = getColor() -export const box = style({ color })` - const graphSpy = spyOn( - importGraphModule, - 'buildStaticImportGraph', - ).mockReturnValue(createSingleFileGraph(filename)) - const compiledSpy = spyOn( - importGraphModule, - 'computeCompiledFiles', - ).mockReturnValue(['src/styles.css.ts']) - readFileSyncSpy.mockImplementation((path: fs.PathOrFileDescriptor) => - String(path).endsWith('styles.css.ts') ? source : '{}', - ) - try { DevupUI({}) @@ -750,9 +701,6 @@ export const box = style({ color })` true, expect.anything(), ) - expect(startCoordinatorSpy).toHaveBeenCalledWith( - expect.objectContaining({ staticVanillaExtract: false }), - ) } finally { graphSpy.mockRestore() compiledSpy.mockRestore() @@ -959,7 +907,6 @@ export const box = style({ color })` prewarmedFiles: [], prewarmedOutputs: expect.any(Map), sourceMap: true, - staticVanillaExtract: false, }) expect(codeExtractSpy).not.toHaveBeenCalled() expect(codeExtractWithoutSourceMapSpy).not.toHaveBeenCalled() diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..a6f6b4370 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -5,7 +5,6 @@ import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' export interface CoordinatorOptions { @@ -48,8 +47,6 @@ export interface CoordinatorOptions { prewarmedOutputs?: Map /** Generate transform source maps. Defaults to true for existing callers. */ sourceMap?: boolean - /** Production-only static `.css.ts` fast path selected with the lite WASM. */ - staticVanillaExtract?: boolean /** * Idle threshold (ms) for the base-css `/css` wait. Defaults to 2500. * FALLBACK ONLY — used when `expectedBaseFiles` is empty (no deterministic @@ -377,7 +374,6 @@ export function startCoordinator(options: CoordinatorOptions): { getCss, } = wasm const prewarmedOutputs = options.prewarmedOutputs ?? new Map() - const staticVanillaExtract = options.staticVanillaExtract ?? false const extract = options.sourceMap === false ? codeExtractWithoutSourceMap : codeExtract @@ -473,9 +469,7 @@ export function startCoordinator(options: CoordinatorOptions): { : takeExtractOutput( extract( filename, - (staticVanillaExtract - ? transformStaticVanillaExtract(filename, code, libPackage) - : undefined) ?? code, + code, libPackage, relCssDir, singleCss, diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..64d97b32e 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -31,7 +31,6 @@ import { } from './coordinator' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' -import { transformStaticVanillaExtract } from './static-vanilla' import { loadWasm, loadWebpackPlugin } from './wasm' /** Options accepted by the Next.js integration. */ @@ -118,11 +117,9 @@ export function resetTurboSetupCacheForTesting(): void { export function selectWasmVariant( graph: StaticImportGraph | undefined, candidateFiles: string[] = graph?.files ?? [], - staticVanillaExtract = false, ): 'lite' | 'full' { return graph && - (staticVanillaExtract || - !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename))) + !candidateFiles.some((filename) => /\.css\.(?:ts|js)$/.test(filename)) ? 'lite' : 'full' } @@ -248,31 +245,7 @@ export function DevupUI( }) : [] const candidateCollectMs = elapsedMs(candidateCollectStartedAt) - const staticVanillaSources = new Map< - string, - { code: string; source: string } - >() - const vanillaCandidates = wasmCandidateFiles.filter((filename) => - /\.css\.(?:ts|js)$/.test(filename), - ) - let staticVanillaExtract = - !watch && !sourceMap && vanillaCandidates.length > 0 - for (const filename of vanillaCandidates) { - if (!staticVanillaExtract) break - const source = readFileSync(resolve(process.cwd(), filename), 'utf-8') - const code = transformStaticVanillaExtract(filename, source, libPackage) - if (code === undefined) { - staticVanillaExtract = false - staticVanillaSources.clear() - break - } - staticVanillaSources.set(filename, { code, source }) - } - const wasmVariant = selectWasmVariant( - staticGraph, - wasmCandidateFiles, - staticVanillaExtract, - ) + const wasmVariant = selectWasmVariant(staticGraph, wasmCandidateFiles) const wasm = loadWasm(wasmVariant === 'lite') const { codeExtract, @@ -452,9 +425,7 @@ export function DevupUI( ).replaceAll('\\', '/')}` const readStartedAt = prewarmStartedAt === undefined ? undefined : performance.now() - const preparedVanilla = staticVanillaSources.get(filename) - const source = - preparedVanilla?.source ?? readFileSync(resourcePath, 'utf-8') + const source = readFileSync(resourcePath, 'utf-8') if (readStartedAt !== undefined) { prewarmReadMs += performance.now() - readStartedAt prewarmSourceBytes += Buffer.byteLength(source) @@ -464,7 +435,7 @@ export function DevupUI( const output = takeExtractOutput( extract( filename, - preparedVanilla?.code ?? source, + source, libPackage, relCssDir, singleCss, @@ -544,7 +515,6 @@ export function DevupUI( prewarmedFiles, prewarmedOutputs, sourceMap, - staticVanillaExtract, }) // Cleanup on exit From 0d29fd8486df8219133a47c9d48a17ed5a7cc907 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:22:40 +0900 Subject: [PATCH 003/109] docs: document library number units and the full vanilla-extract engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 2 +- README_ko.md | 2 +- SKILL.md | 4 ++++ apps/landing/src/app/(detail)/docs/api/box/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/button/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/center/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/grid/page.mdx | 6 ++++++ apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx | 6 ++++++ .../landing/src/app/(detail)/docs/migration/stylex/page.mdx | 2 ++ .../app/(detail)/docs/migration/vanilla-extract/page.mdx | 2 ++ apps/landing/src/app/(detail)/docs/overview/page.mdx | 2 +- packages/components/README.md | 2 +- packages/next-plugin/README.md | 2 +- packages/react/README.md | 2 +- packages/reset-css/README.md | 2 +- packages/rsbuild-plugin/README.md | 2 +- packages/vite-plugin/README.md | 2 +- packages/webpack-plugin/README.md | 2 +- 18 files changed, 48 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 14c0d1658..354d19968 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **5.46s** | **36,507,782 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **5.41s** | 36,586,883 bytes | -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/README_ko.md b/README_ko.md index 1f97db94c..11c0caf7c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -103,7 +103,7 @@ Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙 | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 `lite` WASM 빠른 경로를 사용하며, full Boa evaluator가 필요한 dynamic `.css.ts`는 해당 중앙값에 포함되지 않습니다. +Turbopack 측정 범위는 서로 겹치므로 direct API 결과는 이 fixture에서 Tailwind와 사실상 동률입니다. static `.css.ts` 행은 정적 모듈용 `lite` 빠른 경로가 있던 때에 측정한 값입니다. 지금은 모든 `.css.ts`를 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. ## 작동 원리 diff --git a/SKILL.md b/SKILL.md index cdb07c166..1279357f5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,6 +148,10 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di // padding: 20px (with unit = exact value) ``` +Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses. + +Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, and in `stylex.create()`, `fontSize: 16` is `16px`. + ### Responsive Arrays (5 breakpoints) ```tsx diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index 748acafb2..f1fd2d529 100644 --- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx @@ -65,6 +65,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` ### Rendering as Another Element diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx index 0cbc85daf..a79e01c5f 100644 --- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx @@ -68,3 +68,9 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx index 63783ba43..0ac65ae80 100644 --- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx @@ -85,6 +85,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx index 035921d3c..01c06c307 100644 --- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx @@ -77,6 +77,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index 9133ae7f7..fb7b8b524 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -83,6 +83,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` - `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `grid-area` +- `column-count`, `columns`, `orphans`, `widows`, `zoom` +- `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` +- `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` +- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx index 2dc60054b..f800a8b0a 100644 --- a/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/stylex/page.mdx @@ -33,6 +33,8 @@ const el =
Computed access such as `colors[color]` is resolved against the object the `create()` call was rewritten into, so every generated atom stays reachable. `stylex.attrs()` behaves the same but emits `class` instead of `className`. +Numbers get the unit StyleX gives them: `fontSize: 16` is `16px`, `transitionDuration: 300` is `300ms`, and unitless properties such as `lineHeight` stay as written. A number passed to a dynamic style gets the same unit at runtime. + Accepted argument shapes: ```tsx diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index bb86dca45..09d5accc9 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -20,6 +20,8 @@ export const light = createTheme(vars, { colors: { bg: 'white' } }) export const box = style({ background: vars.colors.bg }) ``` +Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. + ## Ordinary modules `style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: diff --git a/apps/landing/src/app/(detail)/docs/overview/page.mdx b/apps/landing/src/app/(detail)/docs/overview/page.mdx index 9c337acd6..b186af1b8 100644 --- a/apps/landing/src/app/(detail)/docs/overview/page.mdx +++ b/apps/landing/src/app/(detail)/docs/overview/page.mdx @@ -235,7 +235,7 @@ Turbopack values are medians of six cold builds in alternating order. Build size -The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API median is 0.06s (1.1%) ahead but still effectively parity with Tailwind on this fixture. The six cold samples were Tailwind `5.42, 5.56, 5.70, 5.53, 5.41, 5.52s`, direct Devup UI `5.64, 5.40, 5.42, 5.46, 5.54, 5.46s`, and static `.css.ts` `5.42, 5.38, 5.42, 5.47, 5.40, 5.38s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ### Get Started diff --git a/packages/components/README.md b/packages/components/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md index 5775ace8b..facbc7247 100644 --- a/packages/next-plugin/README.md +++ b/packages/next-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/react/README.md b/packages/react/README.md index bc91e68a5..ede5459d6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/reset-css/README.md b/packages/reset-css/README.md index 72d9da7e0..d586eff94 100644 --- a/packages/reset-css/README.md +++ b/packages/reset-css/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 9f00bf49c..404fc2997 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 166b4784b..a943c028c 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md index 83029a18e..343ead564 100644 --- a/packages/webpack-plugin/README.md +++ b/packages/webpack-plugin/README.md @@ -97,7 +97,7 @@ Turbopack values are medians of six cold builds in alternating order: | **devup-ui (direct APIs, single CSS)** | 1.0.40 | **6.54s** | **36,519,234 bytes** | | **devup-ui (static `.css.ts`)** | 1.0.40 | **6.47s** | 36,550,197 bytes | -The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row uses the `lite` WASM fast path; dynamic `.css.ts` modules use the full Boa evaluator and are not represented by that median. +The Turbopack ranges overlap, so the direct-API result is effectively parity with Tailwind on this fixture. The static `.css.ts` row was measured with the former `lite` fast path for static modules; every `.css.ts` module now runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. ## How it works From c212a1a6bb149d484793d7ea6adc07e4d8fa38cf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 04:24:31 +0900 Subject: [PATCH 004/109] chore: add changepack for library number units and full engine Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_library_numbers_full_engine.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changepacks/changepack_log_library_numbers_full_engine.json diff --git a/.changepacks/changepack_log_library_numbers_full_engine.json b/.changepacks/changepack_log_library_numbers_full_engine.json new file mode 100644 index 000000000..248990056 --- /dev/null +++ b/.changepacks/changepack_log_library_numbers_full_engine.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Numbers in vanilla-extract and StyleX styles keep their library's meaning instead of Devup UI's spacing scale: vanilla-extract styles add `px` in `.css.ts` files and in `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, StyleX adds `px`/`ms` to static values and to numbers passed to dynamic styles, and unitless properties such as `columnCount`, `strokeWidth`, `orphans` or `zoom` keep a bare number everywhere. The Next.js plugin now runs every `.css.ts` module on the full engine instead of a separate static fast path, so development and production builds produce the same output", + "date": "2026-09-27T00:00:00.000Z" +} From 8e6e97a883474db49e0a8e0bb5ed525b1d6d496a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:21:05 +0900 Subject: [PATCH 005/109] fix(extractor): fix vanilla-extract evaluation and Emotion/styled-components object styles vanilla-extract: name styles after the variable holding them instead of by call order, generate file-unique names for vars, containers, layers, fonts and theme variables, follow vanilla-extract's createVar/fallbackVar/createTheme/createGlobalTheme/contract/layer semantics, resolve styleVariants variants and transitive composition, and export plain values as data. Emotion and styled-components: numbers in object styles mean pixels, several style arguments or arrays merge, styled(tag, options) is a factory, and css() composes class names passed with rule objects. '@layer' records and a global '@layer' cover nested and responsive declarations, and typography presets yield to declarations written beside them. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../extract_global_style_from_expression.rs | 29 +- .../extract_style_from_expression.rs | 202 + .../extractor/extract_style_from_styled.rs | 15 +- libs/extractor/src/gen_class_name.rs | 2 + libs/extractor/src/import_alias_visit.rs | 183 +- libs/extractor/src/lib.rs | 144 + ...__composed_css_keeps_composed_classes.snap | 39 + ...and_styled_components_object_styles-2.snap | 29 + ...n_and_styled_components_object_styles.snap | 150 + ...layer_record_places_every_declaration.snap | 150 + ...ests__selector_refs_with_global_theme.snap | 6 +- ..._vanilla_extract_all_exports_combined.snap | 14 +- ...r__tests__vanilla_extract_all_imports.snap | 6 +- ...sts__vanilla_extract_constant_exports.snap | 2 +- ...tor__tests__vanilla_extract_container.snap | 6 +- ...__tests__vanilla_extract_create_var-2.snap | 4 +- ...or__tests__vanilla_extract_create_var.snap | 6 +- ...r__tests__vanilla_extract_font_face-2.snap | 6 +- ...tor__tests__vanilla_extract_font_face.snap | 6 +- ...vanilla_extract_font_face_empty_props.snap | 6 +- ...vanilla_extract_font_face_placeholder.snap | 10 +- ..._vanilla_extract_font_face_with_style.snap | 6 +- ...__tests__vanilla_extract_global_theme.snap | 10 +- ...illa_extract_global_theme_placeholder.snap | 10 +- ...anilla_extract_global_theme_with_vars.snap | 10 +- ...__vanilla_extract_imports_combination.snap | 6 +- ...sts__vanilla_extract_keyframes_export.snap | 2 +- ...tractor__tests__vanilla_extract_layer.snap | 12 +- ...__vanilla_extract_layer_and_container.snap | 10 +- ..._tests__vanilla_extract_layer_records.snap | 104 + ...__vanilla_extract_non_exported_styles.snap | 8 +- ...s__vanilla_extract_non_exported_theme.snap | 10 +- ...t_selector_refs_triggers_with_classes.snap | 10 +- ...sts__vanilla_extract_style_variants-2.snap | 2 +- ...tests__vanilla_extract_style_variants.snap | 2 +- ..._vanilla_extract_style_variants_mixed.snap | 2 +- ...illa_extract_style_variants_with_base.snap | 2 +- ...actor__tests__vanilla_extract_theme-2.snap | 18 +- ...tractor__tests__vanilla_extract_theme.snap | 26 +- ...nilla_extract_theme_array_destructure.snap | 20 +- ...nilla_extract_theme_export_variations.snap | 18 +- ..._vanilla_extract_theme_vars_name_only.snap | 6 +- ...ests__vanilla_extract_theme_with_vars.snap | 18 +- ...nilla_extract_theme_without_vars_json.snap | 6 +- ...nilla_extract_theme_without_vars_name.snap | 10 +- ...tor__tests__vanilla_extract_vars_only.snap | 6 +- ...lla_extract_with_keyframes_and_global.snap | 2 +- ...ed_with_variable_like_emotion_props-4.snap | 15 +- ...ed_with_variable_like_emotion_props-5.snap | 15 +- libs/extractor/src/utils.rs | 176 + libs/extractor/src/vanilla_extract.rs | 4429 +++++------------ libs/extractor/src/visit.rs | 43 +- 52 files changed, 2735 insertions(+), 3294 deletions(-) create mode 100644 libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap 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 fd8a70208..5da9cad18 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -10,7 +10,8 @@ use crate::{ extractor::{ GlobalExtractResult, extract_style_from_expression::{ - LiteralHandling, at_rule_record_kind, extract_style_from_expression, + LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer, + yield_typography, }, }, utils::{ @@ -248,23 +249,17 @@ fn collect_global_styles<'a>( }) .unwrap_or_default(); - // Filter out @layer property from styles and set layer on remaining styles - for mut style in extracted.styles { - if let ExtractStyleProp::Static(ExtractStyleValue::Static( - ref mut st, - )) = style - { - // Skip @layer property - it's not a CSS property, set layer on other styles - if st.property() != "@layer" { - if let Some(ref layer) = layer_name { - st.layer = Some(layer.to_string()); - } - styles.push(style); - } - } else { - styles.push(style); - } + // `@layer` names the layer of every other declaration, + // responsive and nested ones included + let mut extracted = extracted.styles; + extracted.retain(|style| { + !matches!(style, ExtractStyleProp::Static(ExtractStyleValue::Static(st)) if st.property() == "@layer") + }); + if let Some(layer) = layer_name { + place_in_layer(&mut extracted, &layer); } + yield_typography(&mut extracted); + styles.extend(extracted); } } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 2d98df84d..bc058ceb2 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -33,6 +33,7 @@ use oxc_ast::{ builder::AstBuilder, }; use oxc_span::SPAN; +use std::collections::BTreeMap; const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; @@ -294,6 +295,34 @@ pub fn extract_style_from_expression<'a>( }; } + // vanilla-extract's `'@layer': { [layerName]: rules }` + if name == "@layer" + && let Expression::ObjectExpression(obj) = expression + { + let mut props = vec![]; + for p in &mut obj.properties { + if let ObjectPropertyKind::ObjectProperty(o) = p + && let Some(layer) = get_string_by_property_key(&o.key) + { + let mut styles = extract_style_from_expression( + ast_builder, + None, + &mut o.value, + level, + selector, + literal_handling, + ) + .styles; + place_in_layer(&mut styles, &layer); + props.extend(styles); + } + } + return ExtractResult { + styles: props, + ..ExtractResult::default() + }; + } + if let Some(kind) = at_rule_record_kind(name) && let Expression::ObjectExpression(obj) = expression { @@ -818,6 +847,169 @@ pub fn extract_style_from_expression<'a>( } } +/// Put every static declaration of `props` in `layer`, nesting the layer one +/// already sits in +pub(crate) fn place_in_layer(props: &mut [ExtractStyleProp<'_>], layer: &str) { + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) => { + style.layer = Some(match style.layer.take() { + Some(inner) => format!("{layer}.{inner}"), + None => layer.to_string(), + }); + } + ExtractStyleProp::StaticArray(props) => place_in_layer(props, layer), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + place_in_layer(std::slice::from_mut(branch.as_mut()), layer); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + place_in_layer(props, layer); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + place_in_layer(std::slice::from_mut(prop.as_mut()), layer); + } + } + // Dynamic values and dynamic `typography` carry no static declaration + ExtractStyleProp::Static(_) | ExtractStyleProp::Expression { .. } => {} + } + } +} + +const TYPOGRAPHY_PROPERTIES: [&str; 5] = [ + "font-family", + "font-size", + "font-weight", + "line-height", + "letter-spacing", +]; + +/// `(selector, typography property)` -> breakpoint its declaration starts at +type Declared = BTreeMap<(Option, &'static str), u8>; + +/// Typography properties `props` always declare, per selector +fn declared_from(props: &[ExtractStyleProp<'_>]) -> Declared { + let mut declared = Declared::new(); + for prop in props { + let found = match prop { + ExtractStyleProp::Static(value) => { + let (property, level, selector) = match value { + ExtractStyleValue::Static(style) => { + (style.property(), style.level(), style.selector()) + } + ExtractStyleValue::Dynamic(style) => { + (style.property(), style.level(), style.selector()) + } + _ => continue, + }; + let Some(property) = TYPOGRAPHY_PROPERTIES.into_iter().find(|p| *p == property) + else { + continue; + }; + Declared::from([((selector.cloned(), property), level)]) + } + ExtractStyleProp::StaticArray(props) => declared_from(props), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => declared_by_every([consequent, alternate].map(|branch| { + branch.as_deref().map_or_else(Declared::new, |branch| { + declared_from(std::slice::from_ref(branch)) + }) + })), + ExtractStyleProp::Enum { map, .. } => { + declared_by_every(map.values().map(|props| declared_from(props))) + } + ExtractStyleProp::MemberExpression { map, .. } => declared_by_every( + map.values() + .map(|prop| declared_from(std::slice::from_ref(prop.as_ref()))), + ), + ExtractStyleProp::Expression { .. } => continue, + }; + for (key, level) in found { + declared + .entry(key) + .and_modify(|from| *from = (*from).min(level)) + .or_insert(level); + } + } + declared +} + +/// What every alternative declares, from the widest breakpoint one starts at +fn declared_by_every(alternatives: impl IntoIterator) -> Declared { + let mut alternatives = alternatives.into_iter(); + let Some(mut common) = alternatives.next() else { + return Declared::new(); + }; + for alternative in alternatives { + common = common + .into_iter() + .filter_map(|(key, level)| alternative.get(&key).map(|other| (key, level.max(*other)))) + .collect(); + } + common +} + +/// A `typography` preset gives way to the declarations written beside it: under +/// the same selector it skips each of their properties from the breakpoint the +/// written one starts at, so a wider breakpoint of the preset cannot win. +pub(crate) fn yield_typography(props: &mut [ExtractStyleProp<'_>]) { + let declared = declared_from(props); + if !declared.is_empty() { + skip_declared(props, &declared); + } +} + +fn skip_declared(props: &mut [ExtractStyleProp<'_>], declared: &Declared) { + for prop in props { + match prop { + ExtractStyleProp::Static(ExtractStyleValue::Static(style)) + if style.property() == "typography" => + { + let skipped: Vec = declared + .iter() + .filter(|((selector, _), _)| selector.as_ref() == style.selector()) + .map(|((_, property), level)| format!("{property}:{level}")) + .collect(); + if !skipped.is_empty() { + style.value = format!("{}|{}", style.value, skipped.join(",")); + } + } + ExtractStyleProp::StaticArray(props) => skip_declared(props, declared), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for branch in [consequent, alternate].into_iter().flatten() { + skip_declared(std::slice::from_mut(branch.as_mut()), declared); + } + } + ExtractStyleProp::Enum { map, .. } => { + for props in map.values_mut() { + skip_declared(props, declared); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + skip_declared(std::slice::from_mut(prop.as_mut()), declared); + } + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Expression { .. } => {} + } + } +} + pub(crate) fn at_rule_record_kind(name: &str) -> Option { match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? { "media" => Some(AtRuleKind::Media), @@ -977,6 +1169,16 @@ mod tests { use oxc_parser::Parser; use oxc_span::SourceType; + #[test] + fn test_declared_by_every() { + assert!(declared_by_every(std::iter::empty()).is_empty()); + let declared = declared_by_every([ + Declared::from([((None, "font-size"), 0), ((None, "line-height"), 1)]), + Declared::from([((None, "font-size"), 2)]), + ]); + assert_eq!(declared, Declared::from([((None, "font-size"), 2)])); + } + #[test] fn test_extract_selector_object_styles() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 492264a49..bad073af9 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -11,7 +11,10 @@ use crate::{ }, gen_class_name::gen_class_names, gen_style::gen_styles, - utils::{merge_object_expressions, unwrap_syntax_only, wrap_array_filter}, + utils::{ + StyleArguments, merge_object_expressions, style_arguments, unwrap_syntax_only, + wrap_array_filter, + }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::{ @@ -104,6 +107,16 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, ) -> (ExtractResult<'a>, Expression<'a>) { + if let Expression::CallExpression(call) = expression + && extract_base_tag_and_class_name(&call.callee, imports) + .0 + .is_some() + && let Some(StyleArguments { classes, rules }) = + style_arguments(ast_builder, &call.arguments) + && classes.is_empty() + { + call.arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); + } 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) diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3101e5667..dad28c98d 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -1,5 +1,6 @@ use crate::ExtractStyleProp; use crate::extract_style::style_property::StyleProperty; +use crate::extractor::extract_style_from_expression::yield_typography; use crate::prop_modify_utils::convert_class_name; use crate::utils::is_same_expression; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -16,6 +17,7 @@ pub fn gen_class_names<'a>( style_order: Option, filename: Option<&str>, ) -> Option> { + yield_typography(style_props); merge_expression_for_class_name( ast_builder, style_props diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index c039151de..c3a0626b6 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -9,30 +9,64 @@ use crate::ImportAlias; use crate::utils::{ - get_str_by_property_key, is_unitless_key, is_vanilla_extract_file, js_number_literal, + 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, ModuleExportName, - ObjectPropertyKind, Statement, + Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, + Statement, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_call_expression, walk_jsx_opening_element}, }; -use oxc_ast_visit::{Visit, walk::walk_call_expression}; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; use std::borrow::Cow; use std::collections::HashMap; -/// Numbers in vanilla-extract `style()` / `globalStyle()` / `keyframes()` rules outside a -/// stylesheet. Those calls become Devup UI's `css()` / `globalCss()` / `keyframes()`, which read a -/// number as its spacing scale, so it is rewritten to the `px` string -/// vanilla-extract makes of it. -struct VanillaNumbers<'n> { - /// Local name of each call and the index of its rules argument - calls: Vec<(&'n str, usize)>, +/// Where a style function takes its rules +#[derive(Clone, Copy)] +enum RulesAt { + Argument(usize), + EveryArgument, +} + +/// Numbers in rules written against vanilla-extract, Emotion or styled-components +/// outside a stylesheet. Those calls become Devup UI's, which read a number as its +/// spacing scale, so it is rewritten to the `px` string the library makes of it. +#[derive(Default)] +struct LibraryNumbers<'n> { + /// Local name of each style function and where its rules are + calls: Vec<(&'n str, RulesAt)>, + /// Local names that build styled components + styled: Vec<&'n str>, + /// Local names of components taking rules in `styles` (Emotion's `Global`) + components: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } -impl VanillaNumbers<'_> { +impl LibraryNumbers<'_> { + /// `styled.div`, `styled(tag)`, `styled(tag, options)` and their + /// `.attrs()` / `.withConfig()`: whatever a call of it passes are rules + fn is_styled_factory(&self, callee: &Expression) -> bool { + match callee { + Expression::StaticMemberExpression(member) => { + matches!(&member.object, Expression::Identifier(root) if self.styled.contains(&root.name.as_str())) + } + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(root) => self.styled.contains(&root.name.as_str()), + Expression::StaticMemberExpression(member) => { + matches!(member.property.name.as_str(), "attrs" | "withConfig") + && self.is_styled_factory(&member.object) + } + _ => false, + }, + _ => false, + } + } + fn pixelify(&mut self, rules: &Expression) { match rules { Expression::ArrayExpression(array) => { @@ -53,7 +87,7 @@ impl VanillaNumbers<'_> { } } else if let Some(number) = js_number_literal(&property.value) && number != 0.0 - && !is_unitless_key(&key) + && !keeps_bare_number(&key) { let span = property.value.span(); self.replacements.push(( @@ -70,19 +104,58 @@ impl VanillaNumbers<'_> { } } -impl<'a> Visit<'a> for VanillaNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'_> { fn visit_call_expression(&mut self, call: &CallExpression<'a>) { - if let Expression::Identifier(callee) = &call.callee - && let Some(&(_, index)) = self + let rules_at = match &call.callee { + Expression::Identifier(callee) => self .calls .iter() .find(|(name, _)| *name == callee.name.as_str()) - && let Some(rules) = call.arguments.get(index).and_then(Argument::as_expression) - { + .map(|(_, rules_at)| *rules_at), + callee => self + .is_styled_factory(callee) + .then_some(RulesAt::EveryArgument), + }; + let rules: Vec<&Expression<'a>> = match rules_at { + Some(RulesAt::Argument(index)) => call + .arguments + .get(index) + .and_then(Argument::as_expression) + .into_iter() + .collect(), + Some(RulesAt::EveryArgument) => call + .arguments + .iter() + .filter_map(Argument::as_expression) + .collect(), + None => vec![], + }; + for rules in rules { self.pixelify(rules); } walk_call_expression(self, call); } + + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + 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); + } + } + } + walk_jsx_opening_element(self, element); + } } /// Map an aliased package's export onto the `@devup-ui/react` export that implements the @@ -156,10 +229,7 @@ pub fn transform_import_aliases<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); - let mut numbers = VanillaNumbers { - calls: Vec::new(), - replacements: Vec::new(), - }; + let mut numbers = LibraryNumbers::default(); for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { @@ -170,22 +240,50 @@ pub fn transform_import_aliases<'a>( let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); transformations.push((span.start as usize, span.end as usize, new_import)); - if source_value == "@vanilla-extract/css" && !redirect_every_name { + if !redirect_every_name { for specifier in import_decl.specifiers.iter().flatten() { - if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { - let rules_index = match imported_name(&spec.imported).as_ref() { - "style" | "keyframes" => 0, - "globalStyle" => 1, - _ => continue, - }; - numbers.calls.push((spec.local.name.as_str(), rules_index)); + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) => { + let local = spec.local.name.as_str(); + match (source_value, imported_name(&spec.imported).as_ref()) { + ("@vanilla-extract/css", "style" | "keyframes") => { + numbers.calls.push((local, RulesAt::Argument(0))); + } + ("@vanilla-extract/css", "globalStyle") => { + numbers.calls.push((local, RulesAt::Argument(1))); + } + ( + "@emotion/react" | "styled-components", + "css" | "keyframes", + ) => numbers.calls.push((local, RulesAt::EveryArgument)), + ("@emotion/react", "Global") => numbers.components.push(local), + _ => {} + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) + if matches!( + source_value, + "@emotion/styled" | "styled-components" + ) => + { + numbers.styled.push(spec.local.name.as_str()); + } + _ => {} } } } } } } - if !numbers.calls.is_empty() { + if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); transformations.sort_unstable_by_key(|(start, ..)| *start); @@ -591,6 +689,29 @@ export const container = style({ background: 'red' })", )); } + #[test] + fn test_emotion_numbers_become_px() { + let code = r"import styled from '@emotion/styled' +import { Global, ThemeProvider } from '@emotion/react' +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: 1, p: 2 }) +export const A = () => <>
"; + let mut aliases = combined_aliases(); + aliases.insert("@emotion/react".to_string(), ImportAlias::NamedToNamed); + assert_eq!( + transform_import_aliases(code, "test.tsx", "@devup-ui/react", &aliases), + r#"import { styled } from '@devup-ui/react'; +import { Global, ThemeProvider } from '@devup-ui/react/compat'; +foo.bar.baz({ top: 1 }) +make()()({ top: 1 }) +;(() => 1)() +styled.div({ top: "1px", p: 2 }) +export const A = () => <>
"# + ); + } + #[test] fn test_vanilla_extract_numbers_become_px_outside_stylesheets() { let code = r"import { style as s, globalStyle, keyframes, styleVariants } from '@vanilla-extract/css' diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b287c1a8f..512c050f1 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11976,6 +11976,66 @@ export const darkTheme = createTheme(vars, { )); } + #[test] + #[serial] + fn test_layer_record_places_every_declaration() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { css } from '@devup-ui/react' +const a = css({ '@layer': { base: { + color: cond ? 'red' : 'blue', + positioning: pos, + bg: { a: 'red', b: 'blue' }[key], + typography: typo, + width: w, + p: [1, 2], + '@layer': { inner: { m: 1 } }, +} } }) +", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new() + } + ) + .unwrap() + )); + } + + #[test] + #[serial] + fn test_vanilla_extract_layer_records() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "layers.css.ts", + r"import { layer, style, globalStyle } from '@vanilla-extract/css' +const reset = layer('reset') +export const components = layer('components') +globalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } }) +export const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } }) +", + 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([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + )]) + } + ) + .unwrap() + )); + } + #[test] #[serial] fn test_vanilla_extract_layer() { @@ -17796,6 +17856,90 @@ export const App = () => <>< )); } + #[test] + #[serial] + fn test_emotion_and_styled_components_object_styles() { + let aliases = HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + for code in [ + r"import styled from '@emotion/styled'; +import { css, keyframes, Global } from '@emotion/react'; +export const a = css({ padding: 8, lineHeight: 1.5, m: 2 }, [{ margin: 1 }, null, undefined, false]); +export const B = styled('div', { shouldForwardProp: (p) => p !== 'x' })({ left: 3 }); +export const C = styled('span', { label: 'c' })`color: red;`; +export const D = styled.div([{ top: 1, color: 'red', '&:hover': { margin: 5 } }, { top: 3, '&:hover': { padding: 6 } }]); +export const E = styled.div({ bottom: 2 }, cond && { right: 4 }); +export const F = styled('p', { color: 'blue' }); +export const spin = keyframes({ to: { width: 10 } }); +export const App = () => <>;", + r"import * as Styled from 'styled-components'; +import { css } from 'styled-components'; +export const G = Styled.div.attrs({ type: 'button' }).withConfig({ displayName: 'g' })({ right: 7 }); +export const H = Styled(Base).withConfig({ displayName: 'h' })({ left: 9 }); +export const I = Styled.div({ width: 1 }); +export const j = css({ height: 2 });", + ] { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: aliases.clone(), + }, + ) + .unwrap() + )); + } + } + + #[test] + #[serial] + fn test_composed_css_keeps_composed_classes() { + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import { style } from '@vanilla-extract/css'; +import { css } from '@emotion/react'; +const base = style({ color: 'red' }); +export const a = style([base, { margin: 2 }]); +export const b = css(base, 'extra', { padding: 1 }); +export const c = css([base]); +export const d = style([cond && base]);", + 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), + ( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed + ) + ]) + }, + ) + .unwrap() + )); + } + #[test] #[serial] fn test_devup_props_typescript_wrappers() { diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap new file mode 100644 index 000000000..9cde0e41c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap @@ -0,0 +1,39 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { style } from '@vanilla-extract/css';\nimport { css } from '@emotion/react';\nconst base = style({ color: 'red' });\nexport const a = style([base, { margin: 2 }]);\nexport const b = css(base, 'extra', { padding: 1 });\nexport const c = css([base]);\nexport const d = style([cond && base]);\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@emotion/react\".to_string(), ImportAlias::NamedToNamed),\n (\"@vanilla-extract/css\".to_string(), ImportAlias::NamedToNamed)])\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = `b ${base}`;\nexport const b = `extra c ${base}`;\nexport const c = base;\nexport const d = \"\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap new file mode 100644 index 000000000..30712f964 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "height", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "width", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = Styled.div.attrs({ type: \"button\" }).withConfig({ displayName: \"g\" })({ right: \"7px\" });\nexport const H = Styled(Base).withConfig({ displayName: \"h\" })({ left: \"9px\" });\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"b\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap new file mode 100644 index 000000000..161dd9866 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -0,0 +1,150 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\", code, ExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases.clone(),\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "1.5", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "5px", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "8px", + level: 0, + selector: Some( + Global( + "body", + "test.tsx", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "6px", + level: 0, + selector: Some( + Selector( + "&:hover", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Keyframes( + ExtractKeyframes { + keyframes: { + "to": [ + ExtractStaticStyle { + property: "width", + value: "10px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ], + }, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const a = \"a b c d\";\nexport const B = ({ style, className, ...rest }) =>
;\nexport const C = ({ style, className, ...rest }) => ;\nexport const D = ({ style, className, ...rest }) =>
;\nexport const E = styled.div({ bottom: \"2px\" }, cond && { right: 4 });\nexport const F = ({ style, className, ...rest }) =>

;\nexport const spin = \"k\";\nexport const App = () => <>;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap new file mode 100644 index 000000000..86d85457d --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap @@ -0,0 +1,150 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import { css } from '@devup-ui/react'\nconst a = css({ '@layer': { base: {\n color: cond ? 'red' : 'blue',\n positioning: pos,\n bg: { a: 'red', b: 'blue' }[key],\n typography: typo,\n width: w,\n p: [1, 2],\n '@layer': { inner: { m: 1 } },\n} } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "bottom", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "margin", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base.inner", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "8px", + level: 1, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "0", + level: 0, + selector: None, + style_order: None, + layer: Some( + "base", + ), + }, + ), + Dynamic( + ExtractDynamicStyle { + property: "width", + level: 0, + identifier: "w", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nconst a = `i k l m ${cond ? \"a\" : \"b\"} ${{\n\t\"bottom\": \"c\",\n\t\"bottom-left\": \"c d\",\n\t\"bottom-right\": \"c e\",\n\t\"left\": \"d\",\n\t\"right\": \"e\",\n\t\"top\": \"f\",\n\t\"top-left\": \"d f\",\n\t\"top-right\": \"e f\"\n}[pos] || \"\"} ${{\n\t\"a\": \"g\",\n\t\"b\": \"h\"\n}[key] || \"\"} ${typo ? `typo-${typo}` : \"\"}`;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap index bb890d2f8..c9f83cbe8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary-global_theme_0-0", + property: "--colors-primary-0-0", value: "blue", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-secondary-global_theme_0-1", + property: "--colors-secondary-0-1", value: "green", level: 0, selector: Some( @@ -63,5 +63,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-0-0)\",\n\t\"secondary\": \"var(--colors-secondary-0-1)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap index 29976b6ec..0f8d800f2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-global_theme_0-0", + property: "--color-primary-0-4", value: "blue", level: 0, selector: Some( @@ -44,7 +44,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-3", level: 0, selector: None, style_order: None, @@ -78,11 +78,17 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer components-0-2;", + file: "all-combined.css.ts", + }, + ), FontFace( ExtractFontFace { file: "all-combined.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-3", "src": "local(Arial)", }, }, @@ -114,5 +120,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"components\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nconst _ve2 = \"b c\";\nconst _ve3 = \"d\";\nconst base = \"b\";\nexport const box = \"e\";\n;\n;\n;\nexport const myVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"components-0-2\";\nexport const myFont = \"font-0-3\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-4)\" } };\nexport const buttons = {\n\t\"primary\": _ve2,\n\t\"secondary\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap index a4a0297fe..43bfc306b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_imports.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_theme", + ".theme-0-0", "all-imports.css.ts", ), ), @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const [theme, vars] = [\"f0_theme\", { \"color\": { \"primary\": \"var(--color-primary-style_0-0)\" } }];\n;\n;\nexport const fadeIn = \"b\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const box = \"b\";\n;\n;\nexport const theme = \"theme-0-0\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap index 611abb463..e1dd693b1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_constant_exports.snap @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const COLORS = {\n\tprimary: \"blue\",\n\tsecondary: \"green\"\n};\nexport const SPACING = 8;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const SPACING = 8;\nexport const COLORS = {\n\t\"primary\": \"blue\",\n\t\"secondary\": \"green\"\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap index 563c444df..b52d5c2eb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "container-name", - value: "__container_0__", + value: "container-0-0", level: 0, selector: None, style_order: None, @@ -32,7 +32,7 @@ ToBTreeSet { selector: Some( At { kind: Container, - query: "__container_0__ (min-width:400px)", + query: "container-0-0 (min-width:400px)", selector: None, outer: [], file: None, @@ -43,5 +43,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"__container_0__\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const responsive = \"a\";\nexport const sidebarContainer = \"b c\";\nexport const sidebar = \"container-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap index 842b47cc1..fe015c8a4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var-2.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0,red)", + value: "var(--var-0-0,red)", level: 0, selector: None, style_order: None, @@ -15,5 +15,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"--var-0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const colorVar = \"var(--var-0-0)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap index 01c826e3f..fc6f9e988 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_create_var.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--var-0", + property: "--var-0-0", value: "blue", level: 0, selector: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0)", + value: "var(--var-0-0)", level: 0, selector: None, style_order: None, @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap index 108b4267d..c66396a1d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face-2.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,7 +18,7 @@ ToBTreeSet { ExtractFontFace { file: "fonts-multi.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-style": "normal", "font-weight": "400", "src": "url(\"/fonts/Roboto.woff2\") format(\"woff2\")", @@ -26,5 +26,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\nconst roboto = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const body = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap index c3ad374c6..2b8fc7ced 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,11 +18,11 @@ ToBTreeSet { ExtractFontFace { file: "fonts.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "src": "local(\"Comic Sans MS\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nconst myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap index 4a785cde4..02e3a50f0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_empty_props.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,10 +18,10 @@ ToBTreeSet { ExtractFontFace { file: "fontface-minimal.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const minimalFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap index 05f207b27..68e3d3981 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_placeholder.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_1", + value: "font-0-1", level: 0, selector: None, style_order: None, @@ -29,7 +29,7 @@ ToBTreeSet { file: "fontface-remap.css.ts", properties: { "font-display": "swap", - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-weight": "400", "src": "url(\"/fonts/custom.woff2\")", }, @@ -39,11 +39,11 @@ ToBTreeSet { ExtractFontFace { file: "fontface-remap.css.ts", properties: { - "font-family": "__devup_font_0_1", + "font-family": "font-0-1", "src": "local(\"Helvetica\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\n;\nexport const customFont = \"__devup_font_0_0\";\nexport const secondFont = \"__devup_font_0_1\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const heading = \"a\";\nexport const text = \"b\";\n;\nexport const customFont = \"font-0-0\";\nexport const secondFont = \"font-0-1\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap index 0f1958b14..ebc837d86 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_font_face_with_style.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: None, style_order: None, @@ -18,12 +18,12 @@ ToBTreeSet { ExtractFontFace { file: "font-usage.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "font-weight": "400", "src": "local(\"Comic Sans MS\")", }, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const text = \"a\";\n;\nexport const myFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap index 891cd1fb3..5a49e712f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-background-global_theme_0-2", + property: "--color-background-0-2", value: "white", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-brand-global_theme_0-0", + property: "--color-brand-0-0", value: "blue", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text-global_theme_0-1", + property: "--color-text-0-1", value: "black", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--font-body-global_theme_0-3", + property: "--font-body-0-3", value: "system-ui,sans-serif", level: 0, selector: Some( @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-global_theme_0-0)\",\n\t\t\"text\": \"var(--color-text-global_theme_0-1)\",\n\t\t\"background\": \"var(--color-background-global_theme_0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-global_theme_0-3)\" }\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-0)\",\n\t\t\"text\": \"var(--color-text-0-1)\",\n\t\t\"background\": \"var(--color-background-0-2)\"\n\t},\n\t\"font\": { \"body\": \"var(--font-body-0-3)\" }\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap index 26b2f670b..f22c68d5e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_placeholder.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg-global_theme_0-0", + property: "--colors-bg-0-0", value: "white", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-bg-global_theme_1-0", + property: "--colors-bg-0-2", value: "black", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-global_theme_0-1", + property: "--colors-text-0-1", value: "black", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-global_theme_1-1", + property: "--colors-text-0-3", value: "white", level: 0, selector: Some( @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_1-0)\",\n\t\"text\": \"var(--colors-text-global_theme_1-1)\"\n} };\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-global_theme_0-0)\",\n\t\"text\": \"var(--colors-text-global_theme_0-1)\"\n} };\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-0)\",\n\t\"text\": \"var(--colors-text-0-1)\"\n} };\nexport const darkVars = { \"colors\": {\n\t\"bg\": \"var(--colors-bg-0-2)\",\n\t\"text\": \"var(--colors-text-0-3)\"\n} };\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap index 09eedf6be..5bb202f10 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme_with_vars.snap @@ -6,7 +6,7 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-global_theme_0-0", + property: "--color-primary-0-0", value: "blue", level: 0, selector: Some( @@ -23,7 +23,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-secondary-global_theme_0-1", + property: "--color-secondary-0-1", value: "green", level: 0, selector: Some( @@ -40,7 +40,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-large-global_theme_0-3", + property: "--space-large-0-3", value: "16px", level: 0, selector: Some( @@ -57,7 +57,7 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-global_theme_0-2", + property: "--space-small-0-2", value: "4px", level: 0, selector: Some( @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-global_theme_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-global_theme_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-global_theme_0-2)\",\n\t\t\"large\": \"var(--space-large-global_theme_0-3)\"\n\t}\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-2)\",\n\t\t\"large\": \"var(--space-large-0-3)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap index 695a64586..3909d8ce8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-family", - value: "__devup_font_0_0", + value: "font-0-0", level: 0, selector: Some( Global( @@ -52,7 +52,7 @@ ToBTreeSet { ExtractFontFace { file: "imports-combo.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-0", "src": "local(Arial)", }, }, @@ -84,5 +84,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"b\";\n;\n;\nexport const fadeIn = \"a\";\nexport const myFont = \"__devup_font_0_0\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n;\n;\nexport const myFont = \"font-0-0\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap index 2cf86d4c6..a425143e6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap @@ -67,5 +67,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"b\";\nconst internal = \"c\";\nexport const spin = \"a\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst internal = \"b\";\nexport const spin = \"a\";\nexport const spinner = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap index 03ac65369..6a8489aa5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer.snap @@ -19,7 +19,7 @@ ToBTreeSet { 0, ), layer: Some( - "reset", + "reset-0-0", ), }, ), @@ -38,10 +38,16 @@ ToBTreeSet { 0, ), layer: Some( - "reset", + "reset-0-0", ), }, ), + Css( + ExtractCss { + css: "@layer reset-0-0,base-0-1,components-0-2;", + file: "layers.css.ts", + }, + ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const base = \"base\";\nexport const components = \"components\";\nexport const reset = \"reset\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const reset = \"reset-0-0\";\nexport const base = \"base-0-1\";\nexport const components = \"components-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap index 3b4e51f96..969713595 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_and_container.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "container-name", - value: "__container_0__", + value: "container-0-2", level: 0, selector: None, style_order: None, @@ -24,6 +24,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer reset-0-0,base-0-1;", + file: "layer-container.css.ts", + }, + ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const containerStyle = \"a b\";\nexport const myContainer = \"__container_0__\";\nexport const baseLayer = \"base\";\nexport const resetLayer = \"reset\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const containerStyle = \"a b\";\nexport const resetLayer = \"reset-0-0\";\nexport const baseLayer = \"base-0-1\";\nexport const myContainer = \"container-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap new file mode 100644 index 000000000..7a5d2d645 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_layer_records.snap @@ -0,0 +1,104 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"layers.css.ts\",\nr\"import { layer, style, globalStyle } from '@vanilla-extract/css'\nconst reset = layer('reset')\nexport const components = layer('components')\nglobalStyle('a', { color: 'blue', '@layer': { [reset]: { color: 'inherit', padding: 0 } } })\nexport const button = style({ color: 'red', '@layer': { [components]: { color: 'green', padding: 4 } } })\n\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"@vanilla-extract/css\".to_string(),\n ImportAlias::NamedToNamed)])\n}).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "blue", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "green", + level: 0, + selector: None, + style_order: None, + layer: Some( + "components-0-1", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "inherit", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "reset-0-0", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "0", + level: 0, + selector: Some( + Global( + "a", + "layers.css.ts", + ), + ), + style_order: Some( + 0, + ), + layer: Some( + "reset-0-0", + ), + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: Some( + "components-0-1", + ), + }, + ), + Css( + ExtractCss { + css: "@layer reset-0-0,components-0-1;", + file: "layers.css.ts", + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const button = \"a b c\";\n;\nexport const components = \"components-0-1\";\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap index b37bcbadb..113b631a8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_styles.snap @@ -24,6 +24,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer internal-0-2;", + file: "mixed-exports.css.ts", + }, + ), Keyframes( ExtractKeyframes { keyframes: { @@ -51,5 +57,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst internalStyle = \"a\";\nexport const publicStyle = \"b\";\nconst internalKeyframe = \"c\";\nconst internalVar = \"--var-0\";\nconst internalContainer = \"__container_0__\";\nconst internalLayer = \"internal\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nconst internalKeyframe = \"a\";\nconst internalStyle = \"b\";\nexport const publicStyle = \"c\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap index fafa89b1f..4508bb9a0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_non_exported_theme.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg-style_0-0", + property: "--colors-bg-0-1", value: "white", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-0", "internal-theme.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text-style_0-1", + property: "--colors-text-0-2", value: "black", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-0", "internal-theme.css.ts", ), ), @@ -49,5 +49,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nconst [internalTheme, themeVars] = [\"f0_internalTheme\", { \"colors\": {\n\t\"bg\": \"var(--colors-bg-style_0-0)\",\n\t\"text\": \"var(--colors-text-style_0-1)\"\n} }];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap index 66d773c71..61195fd1d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap @@ -45,11 +45,17 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@layer ui-0-2;", + file: "refs.css.ts", + }, + ), FontFace( ExtractFontFace { file: "refs.css.ts", properties: { - "font-family": "__devup_font_0_0", + "font-family": "font-0-3", "src": "local(Arial)", }, }, @@ -81,5 +87,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\n;\nexport const fade = \"c\";\nexport const colorVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"ui\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const fade = \"a\";\nexport const child = \"b\";\nexport const parent = \"c\" + \" f0_parent\";\n;\n;\nexport const colorVar = \"var(--var-0-0)\";\nexport const myContainer = \"container-0-1\";\nexport const myLayer = \"ui-0-2\";\nexport const myFont = \"font-0-3\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap index 84a2b9346..8b1746923 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants-2.snap @@ -65,5 +65,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const button = {\n\tprimary: \"a b c d\",\n\tsecondary: \"a b e f\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"a b e f\";\nconst base = \"a b\";\nexport const button = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap index 8426209d6..5334d9dcb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants.snap @@ -35,5 +35,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const background = {\n\tdanger: \"a\",\n\tprimary: \"b\",\n\tsecondary: \"c\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve0 = \"a\";\nconst _ve1 = \"b\";\nconst _ve2 = \"c\";\nexport const background = {\n\t\"primary\": _ve0,\n\t\"secondary\": _ve1,\n\t\"danger\": _ve2\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap index 27943e415..620b8faf2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_mixed.snap @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const buttons = {\n\tdanger: \"a b c\",\n\tprimary: \"a b d e\",\n\tsecondary: \"f g\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c d\";\nconst _ve2 = \"e f\";\nconst _ve3 = \"a b g\";\nconst base = \"a b\";\nexport const buttons = {\n\t\"primary\": _ve1,\n\t\"secondary\": _ve2,\n\t\"danger\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap index 1d2367b8e..9ed8520d8 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_style_variants_with_base.snap @@ -55,5 +55,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a b\";\nexport const sizes = {\n\tlarge: \"a b c\",\n\tmedium: \"a b d\",\n\tsmall: \"a b e\"\n};\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst _ve1 = \"a b c\";\nconst _ve2 = \"a b d\";\nconst _ve3 = \"a b e\";\nconst base = \"a b\";\nexport const sizes = {\n\t\"small\": _ve1,\n\t\"medium\": _ve2,\n\t\"large\": _ve3\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap index db6a47630..a61f4db70 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme-2.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-contract.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "lightblue", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-3", "theme-contract.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "black", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-contract.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "white", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-3", "theme-contract.css.ts", ), ), @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\nexport const darkTheme = \"theme-0-3\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap index 37a0103e4..dbeae3270 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand-style_0-0", + property: "--color-brand-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text-style_0-1", + property: "--color-text-0-2", value: "black", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-large-style_0-4", + property: "--space-large-0-5", value: "16px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-medium-style_0-3", + property: "--space-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -74,12 +74,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-style_0-2", + property: "--space-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme.css.ts", ), ), @@ -92,7 +92,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--color-text-style_0-1)", + value: "var(--color-text-0-2)", level: 0, selector: None, style_order: None, @@ -102,7 +102,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "padding", - value: "var(--space-medium-style_0-3)", + value: "var(--space-medium-0-4)", level: 0, selector: None, style_order: None, @@ -110,5 +110,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const [themeClass, vars] = [\"f0_themeClass\", {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-style_0-0)\",\n\t\t\"text\": \"var(--color-text-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\",\n\t\t\"large\": \"var(--space-large-style_0-4)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"brand\": \"var(--color-brand-0-1)\",\n\t\t\"text\": \"var(--color-text-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\",\n\t\t\"large\": \"var(--space-large-0-5)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap index 9ce62724b..b001724d0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_array_destructure.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary-style_0-0", + property: "--colors-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-secondary-style_0-1", + property: "--colors-secondary-0-2", value: "green", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--spacing-medium-style_0-3", + property: "--spacing-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--spacing-small-style_0-2", + property: "--spacing-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_themeClass", + ".theme-0-0", "theme-array.css.ts", ), ), @@ -75,7 +75,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--colors-primary-style_0-0)", + value: "var(--colors-primary-0-1)", level: 0, selector: None, style_order: None, @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\nexport const [themeClass, themeVars] = [\"f0_themeClass\", {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--colors-secondary-style_0-1)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-style_0-2)\",\n\t\t\"medium\": \"var(--spacing-medium-style_0-3)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const themed = \"a\";\n;\nexport const themeClass = \"theme-0-0\";\nexport const themeVars = {\n\t\"colors\": {\n\t\t\"primary\": \"var(--colors-primary-0-1)\",\n\t\t\"secondary\": \"var(--colors-secondary-0-2)\"\n\t},\n\t\"spacing\": {\n\t\t\"small\": \"var(--spacing-small-0-3)\",\n\t\t\"medium\": \"var(--spacing-medium-0-4)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap index 33392c10c..a04dc8332 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_export_variations.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-bg", + property: "--colors-bg-0-0", value: "gray", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-3", "theme-exports.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-bg", + property: "--colors-bg-0-0", value: "white", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-exports.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text", + property: "--colors-text-0-1", value: "black", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-2", "theme-exports.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--colors-text", + property: "--colors-text-0-1", value: "darkgray", level: 0, selector: Some( Global( - ".f0_internalTheme", + ".theme-0-3", "theme-exports.css.ts", ), ), @@ -83,5 +83,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nconst internalTheme = \"f0_internalTheme\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\n;\nexport const lightTheme = \"theme-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap index c16a72393..af0af474c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_vars_name_only.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_myTheme", + ".theme-0-0", "theme-vars-name.css.ts", ), ), @@ -22,5 +22,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const myTheme = \"f0_myTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const myTheme = [\"theme-0-0\", { \"color\": { \"primary\": \"var(--color-primary-0-1)\" } }];\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap index 9c73d68bd..86248e218 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_with_vars.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-primary-style_0-0", + property: "--color-primary-0-1", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-secondary-style_0-1", + property: "--color-secondary-0-2", value: "green", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -40,12 +40,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-medium-style_0-3", + property: "--space-medium-0-4", value: "8px", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -57,12 +57,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--space-small-style_0-2", + property: "--space-small-0-3", value: "4px", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-0", "theme-vars.css.ts", ), ), @@ -73,5 +73,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const [lightTheme, vars] = [\"f0_lightTheme\", {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-style_0-0)\",\n\t\t\"secondary\": \"var(--color-secondary-style_0-1)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-style_0-2)\",\n\t\t\"medium\": \"var(--space-medium-style_0-3)\"\n\t}\n}];\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const lightTheme = \"theme-0-0\";\nexport const vars = {\n\t\"color\": {\n\t\t\"primary\": \"var(--color-primary-0-1)\",\n\t\t\"secondary\": \"var(--color-secondary-0-2)\"\n\t},\n\t\"space\": {\n\t\t\"small\": \"var(--space-small-0-3)\",\n\t\t\"medium\": \"var(--space-medium-0-4)\"\n\t}\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap index b718003f7..60c87875b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_json.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--colors-primary", + property: "--colors-primary-0-0", value: "blue", level: 0, selector: Some( Global( - ".f0_lightTheme", + ".theme-0-1", "theme-simple.css.ts", ), ), @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\nexport const lightTheme = \"f0_lightTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a\";\n;\nexport const lightTheme = \"theme-0-1\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap index a54ab0006..dc17f9fa9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_theme_without_vars_name.snap @@ -6,12 +6,12 @@ ToBTreeSet { styles: { Static( ExtractStaticStyle { - property: "--color-brand", + property: "--color-brand-0-0", value: "white", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-2", "theme-two-arg.css.ts", ), ), @@ -23,12 +23,12 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "--color-text", + property: "--color-text-0-1", value: "lightgray", level: 0, selector: Some( Global( - ".f0_darkTheme", + ".theme-0-2", "theme-two-arg.css.ts", ), ), @@ -39,5 +39,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const darkTheme = \"f0_darkTheme\";\n;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\n;\nexport const darkTheme = \"theme-0-2\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap index c22f5b86f..3106b9d67 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_vars_only.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "var(--var-0,blue)", + value: "var(--var-0-0,blue)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "font-size", - value: "var(--var-1)", + value: "var(--var-0-1)", level: 0, selector: None, style_order: None, @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"--var-0\";\nexport const sizeVar = \"--var-1\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const box = \"a b\";\nexport const colorVar = \"var(--var-0-0)\";\nexport const sizeVar = \"var(--var-0-1)\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap index 5addf3a3d..bcad15fec 100644 --- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap +++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_keyframes_and_global.snap @@ -41,5 +41,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\nexport const fadeIn = \"b\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const fadeIn = \"a\";\nexport const animated = \"b\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap index 59c8f8892..29fba7a3c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-4.snap @@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ 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: {}, - code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {});\n", + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = ({ style, className, ...rest }) =>

;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap index 86997e507..8c0401c2d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__wrong_styled_with_variable_like_emotion_props-5.snap @@ -3,6 +3,17 @@ source: libs/extractor/src/lib.rs expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {styled} from '@devup-ui/core'\n const StyledDiv = styled(\"div\")({ 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: {}, - code: "const StyledDiv = styled(\"div\")({ bg: \"red\" }, {})``;\n", + styles: { + Static( + ExtractStaticStyle { + property: "background", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst StyledDiv = (({ style, className, ...rest }) =>
)``;\n", } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 3e2c88082..de37c9638 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -36,6 +36,21 @@ pub(super) fn is_unitless_key(key: &str) -> bool { || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) } +/// Whether a number on `key` stays bare in a library whose numbers mean pixels: +/// unitless keys, and Devup UI shorthands (`p`, `bg`, `mx`, ...), which keep +/// Devup UI's spacing scale because no such library defines them. +pub(super) fn keeps_bare_number(key: &str) -> bool { + if is_unitless_key(key) { + return true; + } + let mut properties = css::disassemble_property(key); + let kebab = to_kebab_case(key); + !(properties.len() == 1 + && properties.next().is_some_and(|property| { + property.trim_start_matches('-') == kebab.trim_start_matches('-') + })) +} + /// A JS number literal (`8`, `-8`, `(8)`), unlike a numeric string. pub(super) fn js_number_literal(value: &Expression) -> Option { match value { @@ -463,6 +478,112 @@ pub(super) fn merge_object_expressions<'a>( Some(Expression::new_object_expression(SPAN, props, ast_builder)) } +/// Several style arguments, or arrays of them, as vanilla-extract, Emotion and +/// styled-components compose them +pub(super) struct StyleArguments<'a> { + /// Classes composed as they are: other styles held in variables, strings + pub classes: Vec>, + /// The rule objects merged: later declarations replace earlier ones and + /// nested rules merge + pub rules: Expression<'a>, +} + +/// `None` for a single non-array argument, which needs no composing, or when a +/// part is neither a rule object nor a class (`null`/`undefined`/`false` parts +/// are skipped). +pub(super) fn style_arguments<'a>( + ast_builder: &AstBuilder<'a>, + arguments: &[Argument<'a>], +) -> Option> { + if let [argument] = arguments + && !matches!(argument, Argument::ArrayExpression(_)) + { + return None; + } + let mut objects = Vec::new(); + let mut classes = Vec::new(); + for argument in arguments { + collect_style_parts(argument.as_expression()?, &mut objects, &mut classes)?; + } + let mut merged = oxc_allocator::Vec::new_in(ast_builder); + for object in objects { + merge_style_properties(ast_builder, &mut merged, &object.properties); + } + Some(StyleArguments { + classes: classes + .into_iter() + .map(|class| class.clone_in(ast_builder.allocator())) + .collect(), + rules: Expression::new_object_expression(SPAN, merged, ast_builder), + }) +} + +fn collect_style_parts<'b, 'a>( + expression: &'b Expression<'a>, + objects: &mut Vec<&'b oxc_ast::ast::ObjectExpression<'a>>, + classes: &mut Vec<&'b Expression<'a>>, +) -> Option<()> { + match expression { + Expression::ObjectExpression(object) => objects.push(object), + Expression::ArrayExpression(array) => { + for element in &array.elements { + collect_style_parts(element.as_expression()?, objects, classes)?; + } + } + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => {} + Expression::Identifier(identifier) if identifier.name == "undefined" => {} + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::StringLiteral(_) + | Expression::TemplateLiteral(_) => classes.push(expression), + _ => return None, + } + Some(()) +} + +fn merge_style_properties<'a>( + ast_builder: &AstBuilder<'a>, + merged: &mut oxc_allocator::Vec<'a, ObjectPropertyKind<'a>>, + properties: &[ObjectPropertyKind<'a>], +) { + for property in properties { + let existing = match property { + ObjectPropertyKind::ObjectProperty(property) if !property.computed => { + get_str_by_property_key(&property.key).and_then(|key| { + merged.iter().position(|existing| { + matches!(existing, ObjectPropertyKind::ObjectProperty(existing) + if !existing.computed + && get_str_by_property_key(&existing.key).as_deref() == Some(key.as_ref())) + }) + }) + } + _ => None, + }; + let Some(index) = existing else { + merged.push(property.clone_in(ast_builder.allocator())); + continue; + }; + let previous = merged.remove(index); + if let ( + ObjectPropertyKind::ObjectProperty(previous), + ObjectPropertyKind::ObjectProperty(next), + ) = (&previous, property) + && let (Expression::ObjectExpression(before), Expression::ObjectExpression(after)) = + (&previous.value, &next.value) + { + let mut nested = oxc_allocator::Vec::new_in(ast_builder); + merge_style_properties(ast_builder, &mut nested, &before.properties); + merge_style_properties(ast_builder, &mut nested, &after.properties); + let mut combined = next.clone_in(ast_builder.allocator()); + combined.value = Expression::new_object_expression(SPAN, nested, ast_builder); + merged.push(ObjectPropertyKind::ObjectProperty(combined)); + } else { + merged.push(property.clone_in(ast_builder.allocator())); + } + } +} + /// Borrowing variant of [`get_string_by_property_key`]. /// /// For a `StaticIdentifier` key this returns `Cow::Borrowed`, avoiding the heap @@ -545,6 +666,61 @@ mod tests { assert!(is_unitless_key("--gap")); assert!(is_unitless_key("0")); assert!(!is_unitless_key("padding")); + assert!(keeps_bare_number("lineHeight")); + assert!(keeps_bare_number("p")); + assert!(keeps_bare_number("mx")); + assert!(!keeps_bare_number("padding")); + assert!(!keeps_bare_number("backgroundColor")); + assert!(!keeps_bare_number("WebkitTextStrokeWidth")); + } + + #[test] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_style_arguments() { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let compose = |source: &str| { + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + unreachable!() + }; + let Expression::CallExpression(call) = &statement.expression else { + unreachable!() + }; + style_arguments(&ast_builder, &call.arguments).map( + |StyleArguments { classes, rules }| { + ( + classes + .iter() + .map(expression_to_code) + .collect::>(), + expression_to_code(&rules), + ) + }, + ) + }; + assert_eq!(compose("f({ a: 1 })"), None); + assert_eq!(compose("f(x)"), None); + assert_eq!(compose("f(...x)"), None); + assert_eq!(compose("f([{ a: 1 }, [cond && x]])"), None); + assert_eq!( + compose( + "f([{ a: 1, b: { c: 1 }, d: { e: 1 } }, null, undefined, false, [{ a: 2, b: { f: 2 }, d: 3, [g]: 1, ...h, 'i': 1 }]], { [g]: 2, i: 2 })" + ), + Some(( + std::vec::Vec::new(), + "({a:2,b:{c:1,f:2},d:3,[g]:1,...h,[g]:2,i:2});".to_string() + )) + ); + assert_eq!( + compose("f([x, a.b, a[b], 'c', `d`, { e: 1 }])"), + Some(( + ["x;", "a.b;", "a[b];", "`c`;", "`d`;"] + .map(ToString::to_string) + .to_vec(), + "({e:1});".to_string() + )) + ); } #[test] diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 4d8732b73..2f7cc4262 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,11 +3,11 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. -use crate::utils::is_unitless_key; +use crate::utils::keeps_bare_number; use boa_engine::{ - Context, JsArgs, JsResult, JsValue, NativeFunction, Source, js_string, - object::{FunctionObjectBuilder, ObjectInitializer}, - property::Attribute, + Context, JsArgs, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string, + object::{FunctionObjectBuilder, ObjectInitializer, builtins::JsArray}, + property::{Attribute, PropertyKey}, }; use css::file_map::get_file_num_by_filename; use oxc_allocator::Allocator; @@ -22,81 +22,38 @@ use std::cell::RefCell; use std::path::Path; use std::rc::Rc; -/// A single variant in styleVariants -#[derive(Debug, Clone)] -pub struct StyleVariant { - /// Base class reference (variable name), if composing - pub base: Option, - /// The style object JSON for this variant - pub styles_json: String, -} - -/// Style with export info -#[derive(Debug, Clone)] +/// A `style()` or `keyframes()` call +#[derive(Debug, Clone, Default)] pub struct StyleEntry { - /// The style object JSON + /// The rule object JSON pub json: String, - /// Whether this style is exported + /// Whether the variable holding it is exported pub exported: bool, - /// Base class references for composition (placeholder IDs like "__`style_0`__") + /// Styles composed before this one: placeholders, names once named pub bases: SmallVec<[String; 2]>, + /// Class names composed as they are + pub classes: SmallVec<[String; 1]>, } -/// Entry for `createGlobalTheme()` - CSS variables scoped to a selector -#[derive(Debug, Clone, Default)] -pub struct GlobalThemeEntry { - /// CSS selector (e.g., ":root") - pub selector: String, - /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand-0-0", "blue")] - pub css_vars: SmallVec<[(String, String); 8]>, - /// Serialized JS object with `var()` references - pub vars_object_json: String, - /// Whether this is exported - pub exported: bool, -} - -/// Entry for `createTheme()` - CSS variables scoped to a generated class -#[derive(Debug, Clone, Default)] -pub struct ThemeEntry { - /// CSS variables: Vec<(`var_name`, value)> e.g. [("--color-brand", "blue")] - pub css_vars: SmallVec<[(String, String); 8]>, - /// Whether this is exported - pub exported: bool, - /// For single-arg createTheme: the vars object JSON with `var()` references - /// Used to generate the second element of the returned array - pub vars_object_json: Option, - /// For single-arg createTheme: the name of the vars variable from [themeClass, vars] - pub vars_name: Option, - /// The unique generated class name (`file_prefix` + `variable_name`) - pub class_name: String, -} - -/// Collected style definitions from vanilla-extract API calls +/// What a vanilla-extract stylesheet defines #[derive(Debug, Default, Clone)] pub struct CollectedStyles { - /// `style()` calls: `variable_name` -> (json, exported) + /// `style()` calls, every `styleVariants()` variant included: name -> entry pub styles: FxHashMap, - /// `globalStyle()` calls: selector -> style object JSON - pub global_styles: Vec<(String, String)>, - /// `keyframes()` calls: `variable_name` -> (json, exported) + /// `keyframes()` calls: name -> entry pub keyframes: FxHashMap, - /// `createVar()` calls: `variable_name` -> (CSS variable string, exported) - pub vars: FxHashMap, - /// `fontFace()` calls: `placeholder_id` -> (`font_face` JSON, font-family name, exported) - pub font_faces: FxHashMap, - /// `styleVariants()` calls: `variable_name` -> (variants, exported) - pub style_variants: FxHashMap, bool)>, - /// `createContainer()` calls: `variable_name` -> (container name string, exported) - pub containers: FxHashMap, - /// `layer()` calls: `variable_name` -> (layer name string, exported) - pub layers: FxHashMap, - /// `createGlobalTheme()` calls: `variable_name` -> `GlobalThemeEntry` - pub global_themes: FxHashMap, - /// `createTheme()` calls: `variable_name` -> `ThemeEntry` - pub themes: FxHashMap, - /// Non-style constant exports: `variable_name` -> value (as code string) - pub constant_exports: FxHashMap, - /// `__style_N__` placeholder -> what it refers to, for placeholders left in + /// Global rules in call order: selector -> rule object JSON. Themes declare + /// their variables here, on their class or selector. + pub global_styles: Vec<(String, String)>, + /// `@font-face` rule objects, each naming its `fontFamily` + pub font_faces: Vec, + /// Layers in declaration order + pub layers: Vec, + /// `@property` rules registered by `createVar()` + pub property_rules: Vec, + /// Exported values other than styles and keyframes: name -> code + pub constant_exports: Vec<(String, String)>, + /// `__style_N__` placeholder -> what it stands for, for placeholders left in /// selectors and values pub references: FxHashMap, } @@ -104,279 +61,139 @@ pub struct CollectedStyles { /// Target of a placeholder that a selector or value interpolated #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Reference { - /// `style()`: `variable_name` and the unique class it gains when referenced + /// `style()`: its name and the unique class it gains when referenced Style { name: String, class_name: String }, - /// `keyframes()`: `variable_name`, resolved to the generated keyframes name + /// `keyframes()`: its name, resolved to the generated keyframes name Keyframes(String), - /// `createTheme()`: its generated class name - Class(String), - /// `fontFace()`: its generated font-family name - Font(String), } -/// Internal state for collecting styles during JS execution +/// State the mock API shares while a stylesheet runs #[derive(Default)] -struct StyleCollectorInner { +struct Collector { styles: CollectedStyles, - style_counter: usize, - font_counter: usize, - global_theme_counter: usize, + file_num: usize, + placeholders: usize, + identifiers: usize, } -type StyleCollector = Rc>; - -fn next_style_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__style_{}__", inner.style_counter); - inner.style_counter += 1; - id +type StyleCollector = Rc>; + +impl Collector { + fn placeholder(&mut self) -> String { + let id = format!("__style_{}__", self.placeholders); + self.placeholders += 1; + id + } + + /// A name no other stylesheet produces: `debug_id` (or `fallback`) made an + /// identifier, then the file number and a per-file counter + fn identifier(&mut self, debug_id: Option, fallback: &str) -> String { + let base = debug_id + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| fallback.to_string()); + let mut name: String = base + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + name.insert(0, '_'); + } + let id = format!("{name}-{}-{}", self.file_num, self.identifiers); + self.identifiers += 1; + id + } } -fn next_font_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__font_{}__", inner.font_counter); - inner.font_counter += 1; - id +fn js_str(value: &JsValue) -> Option { + value.as_string().map(|s| s.to_std_string_escaped()) } -fn next_global_theme_id(collector: &StyleCollector) -> String { - let mut inner = collector.borrow_mut(); - let id = format!("__global_theme_{}__", inner.global_theme_counter); - inner.global_theme_counter += 1; - id +fn to_text(value: &JsValue, context: &mut Context) -> JsResult { + Ok(value.to_string(context)?.to_std_string_escaped()) } -/// Parse font-face JSON and return list of (key, value) pairs -/// Input: {"src":"local(...)","fontWeight":400} -/// Output: vec![("src", "\"local(...)\""), ("fontWeight", "400")] -fn parse_font_face_json(json: &str) -> Vec<(String, String)> { - // Use serde_json to parse properly - let Ok(value) = serde_json::from_str::(json) else { - return Vec::new(); - }; - - let Some(obj) = value.as_object() else { - return Vec::new(); - }; - - obj.iter() - .map(|(k, v)| { - let val = match v { - serde_json::Value::String(s) => { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) - } - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => v.to_string(), +/// String and index keys of `object`, in property order +fn own_keys(object: &JsObject, context: &mut Context) -> JsResult> { + Ok(object + .own_property_keys(context)? + .into_iter() + .filter_map(|key| { + let name = match &key { + PropertyKey::String(s) => s.to_std_string_escaped(), + PropertyKey::Index(i) => i.get().to_string(), + PropertyKey::Symbol(_) => return None, }; - (k.clone(), val) + Some((key, name)) }) - .collect() + .collect()) } -/// Recursively transform theme contract object to CSS `var()` references -/// Returns a new JS object with null leaves replaced by var(--path) -fn transform_contract_to_vars( - value: &JsValue, - ctx: &mut Context, - path: &mut Vec, -) -> JsValue { - if let Some(obj) = value.as_object() { - // Check if it's an array - if obj.is_array() { - return value.clone(); - } - - // It's an object - recursively transform each property - let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build(); - - if let Ok(keys) = obj.own_property_keys(ctx) { - for key in keys { - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) { - path.push(key_string.clone()); - - let transformed = transform_contract_to_vars(&prop_value, ctx, path); - - path.pop(); - - let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx); - } - } - } - - JsValue::from(new_obj) - } else { - // Leaf value (should be null) - create CSS variable reference - let var_name = format!("--{}", path.join("-")); - JsValue::from(js_string!(format!("var({})", var_name))) - } +/// Elements of `value` when it is an array +fn array_items(value: &JsValue, context: &mut Context) -> JsResult>> { + let Some(array) = value.as_object().filter(JsObject::is_array) else { + return Ok(None); + }; + let length = array.get(js_string!("length"), context)?.to_u32(context)?; + (0..length) + .map(|index| array.get(index, context)) + .collect::>>() + .map(Some) } -/// Extract CSS variable assignments by matching contract vars with values -fn extract_theme_vars( - contract: &JsValue, - values: &JsValue, - ctx: &mut Context, - css_vars: &mut SmallVec<[(String, String); 8]>, - path: &mut Vec, -) { - if let (Some(contract_obj), Some(values_obj)) = (contract.as_object(), values.as_object()) { - // Both are objects - recurse into properties - if let Ok(keys) = contract_obj.own_property_keys(ctx) { - for key in keys { - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let (Ok(contract_prop), Ok(value_prop)) = ( - contract_obj.get(js_string!(key_string.as_str()), ctx), - values_obj.get(js_string!(key_string.as_str()), ctx), - ) { - path.push(key_string); - - extract_theme_vars(&contract_prop, &value_prop, ctx, css_vars, path); - - path.pop(); - } - } - } - } else if let Some(contract_str) = contract.as_string() { - // Contract leaf is a var(--name) string - let contract_str = contract_str.to_std_string_escaped(); - if contract_str.starts_with("var(") && contract_str.ends_with(')') { - // Extract var name from var(--name) - let var_name = &contract_str[4..contract_str.len() - 1]; - - // Get the value - let value_str = values - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| { - values - .to_string(ctx) - .ok() - .map(|s| s.to_std_string_escaped()) - }) - .unwrap_or_default(); - - css_vars.push((var_name.to_string(), value_str)); - } - } +/// The custom property `var(--x)` reads +fn var_name(reference: &str) -> &str { + reference + .strip_prefix("var(") + .and_then(|name| name.strip_suffix(')')) + .unwrap_or(reference) } -/// Recursively transform theme object to CSS `var()` references -/// Returns a new JS object with the same structure but leaf values replaced with var(--path) -fn transform_theme_to_vars( - value: &JsValue, - ctx: &mut Context, - placeholder_id: &str, - css_vars: &mut SmallVec<[(String, String); 8]>, - var_counter: &mut usize, - path: &mut Vec, -) -> JsValue { - if let Some(obj) = value.as_object() { - // Check if it's an array (shouldn't happen in theme objects, but handle it) - if obj.is_array() { - return value.clone(); - } - - // It's an object - recursively transform each property - let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build(); - - // Get own property keys - if let Ok(keys) = obj.own_property_keys(ctx) { - for key in keys { - // Convert PropertyKey to string - let key_string = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) { - path.push(key_string.clone()); - - let transformed = transform_theme_to_vars( - &prop_value, - ctx, - placeholder_id, - css_vars, - var_counter, - path, - ); - - path.pop(); - - let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx); - } - } - } - - JsValue::from(new_obj) - } else { - // Leaf value - create CSS variable - let var_name = format!( - "--{}-{}-{}", - path.join("-"), - placeholder_id.trim_matches('_').replace("__", "-"), - var_counter - ); - *var_counter += 1; - - // Get the actual value as string - let value_str = value - .as_string() - .map(|s| s.to_std_string_escaped()) - .or_else(|| value.to_string(ctx).ok().map(|s| s.to_std_string_escaped())) - .unwrap_or_default(); - - css_vars.push((var_name.clone(), value_str)); +fn json_string(text: &str) -> String { + serde_json::Value::String(text.to_string()).to_string() +} - // Return var(--name) - JsValue::from(js_string!(format!("var({})", var_name))) - } +/// `JSON.stringify(value, replacer)`, `None` when it produces no string +fn stringify(value: &JsValue, replacer: JsValue, context: &mut Context) -> Option { + let json = context.intrinsics().objects().json(); + let stringify = json.get(js_string!("stringify"), context).ok()?; + stringify + .as_callable()? + .call(&JsValue::undefined(), &[value.clone(), replacer], context) + .ok()? + .as_string() + .map(|s| s.to_std_string_escaped()) } -/// Convert `JsValue` to JSON string using JSON.stringify +/// Serialize `value` as it is fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { - // Use JSON.stringify to convert the value - let json_obj = context.intrinsics().objects().json(); - let stringify_result = json_obj.get(js_string!("stringify"), context); - - if let Ok(stringify) = stringify_result - && let Some(callable) = stringify.as_callable() - && let Ok(result) = - callable.call(&JsValue::undefined(), std::slice::from_ref(value), context) - && let Some(s) = result.as_string() - { - return s.to_std_string_escaped(); - } - - // Fallback: simple conversion using boa_engine 0.21 API - match value.variant() { - boa_engine::value::JsVariant::String(str) => format!("\"{}\"", str.to_std_string_escaped()), - boa_engine::value::JsVariant::Null => "null".to_string(), - boa_engine::value::JsVariant::Undefined => "undefined".to_string(), - _ => "{}".to_string(), - } + stringify(value, JsValue::undefined(), context).unwrap_or_else(|| "{}".to_string()) } /// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, -/// so they are not read as Devup UI's spacing scale. +/// so they are not read as Devup UI's spacing scale, and keying `vars` by the +/// custom properties their `var()` references name. fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { - let key = args.get_or_undefined(0).to_string(context)?; + let key = to_text(args.get_or_undefined(0), context)?; let value = args.get_or_undefined(1); + if key == "vars" + && let Some(vars) = value.as_object() + { + let declared = ObjectInitializer::new(context).build(); + for (property, name) in own_keys(&vars, context)? { + let value = vars.get(property, context)?; + declared.set(js_string!(var_name(&name)), value, false, context)?; + } + return Ok(declared.into()); + } Ok(match value.as_number() { - Some(number) if number != 0.0 && !is_unitless_key(&key.to_std_string_escaped()) => { - JsValue::from(js_string!(format!("{number}px"))) + Some(number) if number != 0.0 && !keeps_bare_number(&key) => { + js_string!(format!("{number}px")).into() } _ => value.clone(), }) @@ -387,342 +204,249 @@ fn style_to_json(value: &JsValue, context: &mut Context) -> String { let realm = context.realm().clone(); let replacer = FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); - let json = context.intrinsics().objects().json(); - if let Ok(stringify) = json.get(js_string!("stringify"), context) - && let Some(callable) = stringify.as_callable() - && let Ok(result) = callable.call( - &JsValue::undefined(), - &[value.clone(), replacer.into()], - context, - ) - && let Some(s) = result.as_string() - { - return s.to_std_string_escaped(); - } - js_value_to_json(value, context) + stringify(value, replacer.into(), context).unwrap_or_else(|| "{}".to_string()) } + /// Execute vanilla-extract style file and collect styles pub fn execute_vanilla_extract( code: &str, package: &str, filename: &str, ) -> Result { - let collector: StyleCollector = Rc::new(RefCell::new(StyleCollectorInner::default())); let file_num = get_file_num_by_filename(filename); - + let collector: StyleCollector = Rc::new(RefCell::new(Collector { + file_num, + ..Collector::default() + })); let mut context = Context::default(); + register_vanilla_extract_apis(&mut context, &collector)?; - // Create the mock module object - register_vanilla_extract_apis(&mut context, collector.clone(), package, file_num)?; - - // Preprocess code: convert TypeScript to JavaScript using Oxc Transformer - let js_code = preprocess_typescript(code, package); - - // Extract variable names from the original code before execution - let var_names = extract_var_names(code, package); - - // Execute the code context - .eval(Source::from_bytes(js_code.as_bytes())) + .eval(Source::from_bytes( + preprocess_typescript(code, package).as_bytes(), + )) .map_err(|e| format!("JS execution error: {e}"))?; - // Map placeholder IDs back to original variable names - let mut result = std::mem::take(&mut collector.borrow_mut().styles); - remap_style_names(&mut result, &var_names, &mut context, file_num); - - Ok(result) + let mut collected = std::mem::take(&mut collector.borrow_mut().styles); + name_entries( + &mut collected, + &top_level_bindings(code), + &mut context, + file_num, + ); + Ok(collected) } -/// Info about a variable declaration -#[derive(Debug, Clone)] -enum VarInfo { - /// A style API call (style, keyframes, createContainer, etc.) - StyleApi { exported: bool, api: StyleApi }, - /// A regular constant export with its original code - Constant(String), - /// The vars object from createTheme array destructuring [themeClass, vars] - ThemeVars, +/// A name a top-level variable declaration of the stylesheet binds +struct Binding { + name: String, + exported: bool, + /// Initializer source, when the declaration binds this name alone + init: Option, } -/// Extract all variable names and their info from the original code -/// Returns (`style_api_vars`, `exported_constants`) -fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> { +fn top_level_bindings(code: &str) -> Vec { let allocator = Allocator::default(); - let source_type = SourceType::ts(); - let ret = Parser::new(&allocator, code, source_type).parse(); - - let mut vars = Vec::new(); - let mut collect_decl = |decl: &oxc_ast::ast::VariableDeclarator<'_>, exported: bool| { - let Some(init) = &decl.init else { - return; + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let mut bindings = Vec::new(); + for statement in &program.body { + let (declaration, exported) = match statement { + oxc_ast::ast::Statement::ExportDeclaration(export) => match &export.declaration { + oxc_ast::ast::Declaration::VariableDeclaration(declaration) => (declaration, true), + _ => continue, + }, + oxc_ast::ast::Statement::VariableDeclaration(declaration) => (declaration, false), + _ => continue, }; - - // Check for array destructuring: const [themeClass, vars] = createTheme(...) - if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id { - if let Some(api) = style_api_call(init) { - // First element is the theme class - if let Some(Some(first)) = array_pat.elements.first() - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first - { - vars.push((id.name.to_string(), VarInfo::StyleApi { exported, api })); - } - // Second element is the vars object - mark as ThemeVars - if let Some(Some(second)) = array_pat.elements.get(1) - && let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = second - { - vars.push((id.name.to_string(), VarInfo::ThemeVars)); - } - } - } else if let Some(name) = decl.id.get_identifier_name() { - if let Some(api) = style_api_call(init) { - vars.push((name.to_string(), VarInfo::StyleApi { exported, api })); - } else if exported { - // Extract the original init expression using span - let span = init.span(); - let init_code = &code[span.start as usize..span.end as usize]; - vars.push((name.to_string(), VarInfo::Constant(init_code.to_string()))); - } - } - }; - - for stmt in &ret.program.body { - match stmt { - // Exported variable declarations - oxc_ast::ast::Statement::ExportDeclaration(export) => { - if let oxc_ast::ast::Declaration::VariableDeclaration(var_decl) = - &export.declaration - { - for decl in &var_decl.declarations { - collect_decl(decl, true); - } - } + for declarator in &declaration.declarations { + let init = declarator + .init + .as_ref() + .filter(|_| declarator.id.get_identifier_name().is_some()) + .map(|init| code[init.span().start as usize..init.span().end as usize].to_string()); + for identifier in declarator.id.get_binding_identifiers() { + bindings.push(Binding { + name: identifier.name.to_string(), + exported, + init: init.clone(), + }); } - // Non-exported variable declarations - oxc_ast::ast::Statement::VariableDeclaration(var_decl) => { - for decl in &var_decl.declarations { - collect_decl(decl, false); - } - } - _ => {} } } - - vars -} - -/// Which placeholder counter a style API call draws from -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StyleApi { - FontFace, - GlobalTheme, - Other, + bindings } -/// The style API an expression calls (style, keyframes, styleVariants, etc.) -fn style_api_call(expr: &oxc_ast::ast::Expression) -> Option { - if let oxc_ast::ast::Expression::CallExpression(call) = expr - && let oxc_ast::ast::Expression::Identifier(id) = &call.callee - { - return match id.name.as_str() { - "fontFace" => Some(StyleApi::FontFace), - "createGlobalTheme" => Some(StyleApi::GlobalTheme), - "style" | "keyframes" | "styleVariants" | "createVar" | "createContainer" | "layer" - | "createTheme" => Some(StyleApi::Other), - _ => None, - }; - } - None +fn placeholder_index(id: &str) -> usize { + id.trim_start_matches("__style_") + .trim_end_matches("__") + .parse() + .unwrap_or_default() } -/// Remap style placeholder IDs to original variable names -fn remap_style_names( +/// Name every style and keyframes after the variable holding it (`_veN` when +/// none does), and turn the other exported values into code. +/// +/// Names come from the values the variables hold after the stylesheet ran, so +/// calls nested in objects, arrays or helpers do not shift them. +fn name_entries( collected: &mut CollectedStyles, - vars: &[(String, VarInfo)], - _context: &mut Context, + bindings: &[Binding], + context: &mut Context, file_num: usize, ) { - // Generate a file-based prefix for unique class names - // e.g., file_num 0 -> "f0" - let file_prefix = format!("f{file_num}"); - // Build mapping from placeholder ID to original name - // The order of style() calls matches the order of variable declarations - let mut placeholder_to_name: FxHashMap = FxHashMap::default(); - let mut new_styles = FxHashMap::default(); - let mut new_keyframes = FxHashMap::default(); - let mut new_style_variants = FxHashMap::default(); - let mut new_vars = FxHashMap::default(); - let mut new_containers = FxHashMap::default(); - let mut new_layers = FxHashMap::default(); - let mut new_font_faces = FxHashMap::default(); - let mut new_global_themes = FxHashMap::default(); - let mut new_themes = FxHashMap::default(); - let mut style_idx = 0; - let mut font_idx = 0; - let mut global_theme_idx = 0; - // Track the last processed theme's vars_object_json for ThemeVars handling - let mut last_theme_vars_json: Option = None; - - // First pass: take ownership of old entries (avoids drain+collect overhead) - let mut old_styles = std::mem::take(&mut collected.styles); - let mut old_keyframes = std::mem::take(&mut collected.keyframes); - let mut old_style_variants = std::mem::take(&mut collected.style_variants); - let mut old_vars = std::mem::take(&mut collected.vars); - let mut old_containers = std::mem::take(&mut collected.containers); - let mut old_layers = std::mem::take(&mut collected.layers); - let mut old_font_faces = std::mem::take(&mut collected.font_faces); - let mut old_global_themes = std::mem::take(&mut collected.global_themes); - let mut old_themes = std::mem::take(&mut collected.themes); - - for (name, info) in vars { - match info { - VarInfo::StyleApi { - exported, - api: StyleApi::FontFace, - } => { - let font_placeholder = format!("__font_{font_idx}__"); - if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) { - collected - .references - .insert(font_placeholder, Reference::Font(font_family.clone())); - new_font_faces.insert(name.clone(), (json, font_family, *exported)); - font_idx += 1; - } - } - VarInfo::StyleApi { - exported, - api: StyleApi::GlobalTheme, - } => { - let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__"); - if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) { - entry.exported = *exported; - new_global_themes.insert(name.clone(), entry); - global_theme_idx += 1; - } - } - VarInfo::StyleApi { - exported, - api: StyleApi::Other, - } => { - let placeholder = format!("__style_{style_idx}__"); - placeholder_to_name.insert(placeholder.clone(), name.clone()); - - if let Some(mut entry) = old_styles.remove(&placeholder) { - entry.exported = *exported; - new_styles.insert(name.clone(), entry); - collected.references.insert( - placeholder, - Reference::Style { - name: name.clone(), - class_name: format!("{file_prefix}_{name}"), - }, - ); - style_idx += 1; - } else if let Some(mut entry) = old_keyframes.remove(&placeholder) { - entry.exported = *exported; - new_keyframes.insert(name.clone(), entry); - collected - .references - .insert(placeholder, Reference::Keyframes(name.clone())); - style_idx += 1; - } else if let Some((variants, _)) = old_style_variants.remove(&placeholder) { - new_style_variants.insert(name.clone(), (variants, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_vars.remove(&placeholder) { - new_vars.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_containers.remove(&placeholder) { - new_containers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some((value, _)) = old_layers.remove(&placeholder) { - new_layers.insert(name.clone(), (value, *exported)); - style_idx += 1; - } else if let Some(mut entry) = old_themes.remove(&placeholder) { - // Track this theme name for the next ThemeVars entry - if entry.vars_object_json.is_some() { - last_theme_vars_json = Some(name.clone()); - } + let values: Vec<(&Binding, JsValue)> = bindings + .iter() + .filter_map(|binding| { + context + .eval(Source::from_bytes(binding.name.as_bytes())) + .ok() + .map(|value| (binding, value)) + }) + .collect(); - // Generate unique class name: file_prefix + variable_name - let class_name = format!("{file_prefix}_{name}"); - - // Add CSS variables to global_styles with class selector - if !entry.css_vars.is_empty() { - let vars_json = format!( - "{{ {} }}", - entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", ") - ); - collected - .global_styles - .push((format!(".{class_name}"), vars_json)); - } + let mut names: FxHashMap = FxHashMap::default(); + for (binding, value) in &values { + if let Some(id) = js_str(value) + && !names.contains_key(&id) + && let Some(entry) = collected + .styles + .get_mut(&id) + .or_else(|| collected.keyframes.get_mut(&id)) + { + entry.exported = binding.exported; + names.insert(id, binding.name.clone()); + } + } - collected - .references - .insert(placeholder, Reference::Class(class_name.clone())); - entry.exported = *exported; - entry.vars_name = None; - entry.class_name = class_name; - new_themes.insert(name.clone(), entry); - style_idx += 1; - } - } - VarInfo::ThemeVars => { - // This is the vars object from [themeClass, vars] = createTheme(...) - // Set vars_name on the theme we just processed - if let Some(theme_name) = last_theme_vars_json.take() - && let Some(theme_entry) = new_themes.get_mut(&theme_name) - { - theme_entry.vars_name = Some(name.clone()); - } - } - VarInfo::Constant(code) => { - collected - .constant_exports - .insert(name.clone(), code.clone()); - } + let declared: FxHashSet<&str> = bindings + .iter() + .map(|binding| binding.name.as_str()) + .collect(); + let mut anonymous: Vec = collected + .styles + .keys() + .chain(collected.keyframes.keys()) + .filter(|id| !names.contains_key(*id)) + .cloned() + .collect(); + anonymous.sort_by_key(|id| placeholder_index(id)); + for id in anonymous { + let mut name = format!("_ve{}", placeholder_index(&id)); + while declared.contains(name.as_str()) { + name.push('_'); } + names.insert(id, name); } - // Remap base references in styleVariants - for (variants, _) in new_style_variants.values_mut() { - for variant in variants.values_mut() { - if let Some(base) = &variant.base - && let Some(name) = placeholder_to_name.get(base) - { - variant.base = Some(name.clone()); - } + for (binding, value) in &values { + let names_entry = js_str(value).is_some_and(|id| names.get(&id) == Some(&binding.name)); + if binding.exported + && !names_entry + && let Some(code) = value_to_code(value, context, &names, &mut Vec::new()) + .or_else(|| binding.init.clone()) + { + collected + .constant_exports + .push((binding.name.clone(), code)); } } - // Remap base references in styles (for composition) - for entry in new_styles.values_mut() { - let old_bases = std::mem::take(&mut entry.bases); - entry.bases = old_bases - .into_iter() - .map(|b| { - if let Some(name) = placeholder_to_name.get(&b) { - name.clone() - } else { - b + for (id, name) in &names { + let reference = if let Some(mut entry) = collected.styles.remove(id) { + for base in &mut entry.bases { + if let Some(base_name) = names.get(base.as_str()) { + base_name.clone_into(base); } - }) - .collect(); + } + collected.styles.insert(name.clone(), entry); + Reference::Style { + name: name.clone(), + class_name: format!("f{file_num}_{name}"), + } + } else { + if let Some(entry) = collected.keyframes.remove(id) { + collected.keyframes.insert(name.clone(), entry); + } + Reference::Keyframes(name.clone()) + }; + collected.references.insert(id.clone(), reference); + } +} + +fn is_plain_object(object: &JsObject, context: &Context) -> bool { + object.prototype().is_none_or(|prototype| { + JsObject::equals( + &prototype, + &context.intrinsics().constructors().object().prototype(), + ) + }) +} + +/// JavaScript for a data `value`, placeholders turned into the names of their +/// styles; `None` for functions, class instances, symbols and cycles +fn value_to_code( + value: &JsValue, + context: &mut Context, + names: &FxHashMap, + seen: &mut Vec, +) -> Option { + if let Some(text) = js_str(value) { + return Some(string_code(&text, names)); + } + if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { + return to_text(value, context).ok(); + } + let object = value.as_object()?; + if object.is_callable() + || seen + .iter() + .any(|visited| JsObject::equals(visited, &object)) + { + return None; } + seen.push(object.clone()); + let mut parts = Vec::new(); + let code = if let Some(items) = array_items(value, context).ok()? { + for item in &items { + parts.push(value_to_code(item, context, names, seen)?); + } + format!("[{}]", parts.join(", ")) + } else if is_plain_object(&object, context) { + for (key, name) in own_keys(&object, context).ok()? { + let item = object.get(key, context).ok()?; + parts.push(format!( + "{}: {}", + json_string(&name), + value_to_code(&item, context, names, seen)? + )); + } + if parts.is_empty() { + "{}".to_string() + } else { + format!("{{ {} }}", parts.join(", ")) + } + } else { + return None; + }; + seen.pop(); + Some(code) +} - collected.styles = new_styles; - collected.keyframes = new_keyframes; - collected.style_variants = new_style_variants; - collected.vars = new_vars; - collected.containers = new_containers; - collected.layers = new_layers; - collected.font_faces = new_font_faces; - collected.global_themes = new_global_themes; - collected.themes = new_themes; +/// A string literal, or a template literal when it interpolates placeholders +fn string_code(text: &str, names: &FxHashMap) -> String { + if let Some(name) = names.get(text) { + return name.clone(); + } + let escaped = text + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${"); + replace_placeholders(&escaped, |token, _| { + names.get(token).map(|name| format!("${{{name}}}")) + }) + .map_or_else(|| json_string(text), |template| format!("`{template}`")) } /// Replace every `__name__` token that `lookup` resolves; `None` when nothing @@ -757,6 +481,7 @@ fn replace_placeholders( output }) } + /// Convert TypeScript to JavaScript using Oxc Transformer and replace imports fn preprocess_typescript(code: &str, package: &str) -> String { let allocator = Allocator::default(); @@ -778,41 +503,52 @@ fn preprocess_typescript(code: &str, package: &str) -> String { // Generate JavaScript let js_code = Codegen::new().build(&program).code; - // Replace import from package with our mock object destructuring - // e.g., import { style } from '@devup-ui/react' -> const { style } = __vanilla_extract__; - // Note: Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit + // Bind imports of the package to the mock object instead, e.g. + // `import { style } from '@devup-ui/react'` -> `const { style } = __vanilla_extract__;` + // Import aliases (like @vanilla-extract/css) are already transformed by import_alias_visit let import_patterns = [format!("from \"{package}\""), format!("from '{package}'")]; - - // Process all import patterns (multiple imports may exist) let mut transformed = String::with_capacity(js_code.len()); for (idx, line) in js_code.lines().enumerate() { if idx > 0 { transformed.push('\n'); } - let mut matched = false; - for pattern in &import_patterns { - if line.contains(pattern) { - // Extract imported names from: import { a, b, c } from 'package' - if let Some(start) = line.find('{') - && let Some(end) = line.find('}') - { - let imports = &line[start + 1..end]; - transformed.push_str("const {"); - transformed.push_str(imports); - transformed.push_str("} = __vanilla_extract__;"); - matched = true; - break; - } - } - } - if !matched { + if import_patterns + .iter() + .any(|pattern| line.contains(pattern.as_str())) + && let Some(binding) = mock_binding(line) + { + transformed.push_str("const "); + transformed.push_str(&binding); + transformed.push_str(" = __vanilla_extract__;"); + } else { transformed.push_str(strip_export_keyword(line)); } } transformed } +/// What an import of the package binds: `{ a, b: c }` for named imports, the +/// namespace for `import * as ns` +fn mock_binding(line: &str) -> Option { + if let (Some(start), Some(end)) = (line.find('{'), line.find('}')) { + let names: Vec = line[start + 1..end] + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.replacen(" as ", ": ", 1)) + .collect(); + return Some(format!("{{ {} }}", names.join(", "))); + } + line.strip_prefix("import * as ") + .and_then(|rest| rest.split_whitespace().next()) + .map(ToString::to_string) +} + fn strip_export_keyword(line: &str) -> &str { + // The transformer marks a module whose imports it all removed with `export {};` + if line == "export {};" { + return ""; + } line.strip_prefix("export ").map_or(line, |rest| { if rest.starts_with("const ") || rest.starts_with("let ") @@ -826,394 +562,581 @@ fn strip_export_keyword(line: &str) -> &str { }) } +type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; + +fn api(collector: &StyleCollector, function: Api) -> NativeFunction { + let collector = collector.clone(); + // SAFETY: the closure captures only an `Rc>`, which holds no + // garbage-collected value, and a stylesheet runs on a single thread + unsafe { + NativeFunction::from_closure(move |_this, args, context| { + function(&collector, args, context) + }) + } +} + /// Register vanilla-extract mock APIs in the JS context fn register_vanilla_extract_apis( context: &mut Context, - collector: StyleCollector, - _package: &str, - file_num: usize, + collector: &StyleCollector, ) -> Result<(), String> { - // style() function - let collector_style = collector.clone(); - // SAFETY: The closure only captures Rc> which is safe to use in single-threaded JS context - let style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let style_obj = args.get_or_undefined(0); - let id = next_style_id(&collector_style); - - // Check if argument is an array (composition syntax) - let (json, bases) = if let Some(obj) = style_obj.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), ctx) - && let Some(len) = length_val.as_number() - { - // It's an array - handle composition - let len = len as u32; - let mut base_classes = SmallVec::new(); - let mut merged_styles = String::from("{"); - let mut first_style = true; - - for i in 0..len { - if let Ok(elem) = obj.get(i, ctx) { - if let Some(base_str) = elem.as_string() { - // It's a base class reference (string) - base_classes.push(base_str.to_std_string_escaped()); - } else if elem.is_object() { - // It's a style object - merge it - let elem_json = style_to_json(&elem, ctx); - // Strip outer braces and merge - let inner = elem_json - .trim() - .trim_start_matches('{') - .trim_end_matches('}') - .trim(); - if !inner.is_empty() { - if !first_style { - merged_styles.push(','); - } - merged_styles.push_str(inner); - first_style = false; - } - } - } - } - merged_styles.push('}'); - (merged_styles, base_classes) - } else { - // No length property, just a style object - (style_to_json(style_obj, ctx), SmallVec::new()) - }; - collector_style.borrow_mut().styles.styles.insert( - id.clone(), - StyleEntry { - json, - exported: false, // Will be updated in remap_style_names - bases, - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; - - // globalStyle() function - let collector_global = collector.clone(); - let global_style_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let selector = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let style_obj = args.get_or_undefined(1); - let json = style_to_json(style_obj, ctx); - - collector_global - .borrow_mut() - .styles - .global_styles - .push((selector, json)); + let apis = [ + ("style", api(collector, style), 1), + ("globalStyle", api(collector, global_style), 2), + ("styleVariants", api(collector, style_variants), 1), + ( + "keyframes", + api(collector, |collector, args, context| { + Ok(keyframes(collector, args, context)) + }), + 1, + ), + ("fontFace", api(collector, font_face), 1), + ("globalFontFace", api(collector, global_font_face), 2), + ("createVar", api(collector, create_var), 0), + ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), + ( + "createContainer", + api(collector, |collector, args, _context| { + Ok(create_container(collector, args)) + }), + 0, + ), + ("layer", api(collector, layer), 0), + ("globalLayer", api(collector, global_layer), 1), + ( + "createThemeContract", + api(collector, create_theme_contract), + 1, + ), + ( + "createGlobalThemeContract", + NativeFunction::from_fn_ptr(create_global_theme_contract), + 1, + ), + ( + "assignVars", + NativeFunction::from_fn_ptr(assign_vars_api), + 2, + ), + ("createTheme", api(collector, create_theme), 1), + ("createGlobalTheme", api(collector, create_global_theme), 2), + ]; + let mut builder = ObjectInitializer::new(context); + for (name, function, length) in apis { + builder.function(function, JsString::from(name), length); + } + let mock = builder.build(); + context + .register_global_property(js_string!("__vanilla_extract__"), mock, Attribute::all()) + .map_err(|e| format!("Failed to register __vanilla_extract__: {e}")) +} - Ok(JsValue::undefined()) - }) - }; +fn style(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let id = register_style(collector, args.get_or_undefined(0), context)?; + Ok(js_string!(id).into()) +} - // keyframes() function - let collector_keyframes = collector.clone(); - let keyframes_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let keyframes_obj = args.get_or_undefined(0); - let json = style_to_json(keyframes_obj, ctx); - let id = next_style_id(&collector_keyframes); - - collector_keyframes.borrow_mut().styles.keyframes.insert( - id.clone(), - StyleEntry { - json, - exported: false, - bases: SmallVec::new(), - }, - ); - - Ok(JsValue::from(js_string!(id))) - }) - }; +fn register_style( + collector: &StyleCollector, + rule: &JsValue, + context: &mut Context, +) -> JsResult { + let mut entry = StyleEntry::default(); + let mut rules = Vec::new(); + compose(rule, &mut entry, &mut rules, context)?; + entry.json = format!("{{{}}}", rules.join(",")); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.styles.insert(id.clone(), entry); + Ok(id) +} - // createVar() function - // Returns CSS custom property name like "--var-0", not wrapped in var() - // When used as a key in vars: {[colorVar]: 'blue'}, it becomes the property name - // When used as a value, the extraction logic will wrap it in var() - let collector_var = collector.clone(); - let create_var_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_var); - let var_name = format!("--var-{}", collector_var.borrow().styles.vars.len()); - collector_var - .borrow_mut() - .styles - .vars - .insert(id, (var_name.clone(), false)); - // Return just the CSS custom property name, without var() wrapper - Ok(JsValue::from(js_string!(var_name))) - }) - }; +fn is_placeholder(token: &str) -> bool { + token.starts_with("__style_") && token.ends_with("__") +} - // fontFace() function - // Returns a placeholder ID that will be replaced with generated font-family name - let collector_font = collector.clone(); - let font_face_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let font_obj = args.get_or_undefined(0); - let json = js_value_to_json(font_obj, ctx); - let id = next_font_id(&collector_font); - - // Generate a unique font-family name for this font - // Include file_num to ensure uniqueness across different files - let font_family = format!( - "__devup_font_{}_{}", - file_num, - collector_font.borrow().font_counter - 1 - ); - - collector_font - .borrow_mut() - .styles - .font_faces - .insert(id.clone(), (json, font_family, false)); +/// Sort a composition list into the styles and classes it composes and the +/// inner JSON of its rules +fn compose( + value: &JsValue, + entry: &mut StyleEntry, + rules: &mut Vec, + context: &mut Context, +) -> JsResult<()> { + if let Some(items) = array_items(value, context)? { + for item in &items { + compose(item, entry, rules, context)?; + } + } else if let Some(classes) = js_str(value) { + for class in classes.split_whitespace() { + if is_placeholder(class) { + entry.bases.push(class.to_string()); + } else { + entry.classes.push(class.to_string()); + } + } + } else if value.is_object() { + let json = style_to_json(value, context); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); + } + } + Ok(()) +} - // Return the placeholder ID - will be replaced in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +fn global_style( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let json = style_to_json(args.get_or_undefined(1), context); + collector + .borrow_mut() + .styles + .global_styles + .push((selector, json)); + Ok(JsValue::undefined()) +} - // styleVariants() function - let collector_variants = collector.clone(); - let style_variants_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let variants_obj = args.get_or_undefined(0); - let variants = parse_style_variants(variants_obj, ctx); - let id = next_style_id(&collector_variants); +/// `styleVariants(variants, map?)`: a style per key, of the value or of what +/// `map(value, key)` returns +fn style_variants( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let variants = ObjectInitializer::new(context).build(); + let map = args.get_or_undefined(1).as_callable(); + if let Some(data) = args.get_or_undefined(0).as_object() { + for (key, name) in own_keys(&data, context)? { + let value = data.get(key, context)?; + let rule = match &map { + Some(map) => map.call( + &JsValue::undefined(), + &[value, js_string!(name.as_str()).into()], + context, + )?, + None => value, + }; + let id = register_style(collector, &rule, context)?; + variants.set(js_string!(name), js_string!(id), false, context)?; + } + } + Ok(variants.into()) +} - collector_variants - .borrow_mut() - .styles - .style_variants - .insert(id.clone(), (variants, false)); +fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { + let json = style_to_json(args.get_or_undefined(0), context); + let mut collector = collector.borrow_mut(); + let id = collector.placeholder(); + collector.styles.keyframes.insert( + id.clone(), + StyleEntry { + json, + ..StyleEntry::default() + }, + ); + js_string!(id).into() +} - // Return an object placeholder - the actual object will be built in code generation - Ok(JsValue::from(js_string!(id))) - }) - }; +/// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them +fn font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let family = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(1)), "font"); + declare_font_faces(collector, &family, args.get_or_undefined(0), context)?; + Ok(js_string!(family).into()) +} - // fallbackVar() function - returns var(--x, fallback) format - let fallback_var_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let var_value = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - let fallback = args - .get_or_undefined(1) - .to_string(ctx)? - .to_std_string_escaped(); - - // var_value is now just "--var-0" (CSS custom property name) - // Return var(--var-0, fallback) - let result = format!("var({var_value}, {fallback})"); - Ok(JsValue::from(js_string!(result))) - }) - }; +fn global_font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let family = to_text(args.get_or_undefined(0), context)?; + declare_font_faces(collector, &family, args.get_or_undefined(1), context)?; + Ok(JsValue::undefined()) +} - // createThemeContract() function - transforms null leaves to var(--path) references - let create_theme_contract_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let contract_obj = args.get_or_undefined(0); - let transformed = transform_contract_to_vars(contract_obj, ctx, &mut Vec::new()); - Ok(transformed) - }) - }; +fn declare_font_faces( + collector: &StyleCollector, + family: &str, + rules: &JsValue, + context: &mut Context, +) -> JsResult<()> { + let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); + let family = json_string(family); + for rule in &rules { + let json = js_value_to_json(rule, context); + let inner = inner_json(&json); + let face = if inner.is_empty() { + format!("{{\"fontFamily\":{family}}}") + } else { + format!("{{{inner},\"fontFamily\":{family}}}") + }; + collector.borrow_mut().styles.font_faces.push(face); + } + Ok(()) +} - // createTheme() function - creates a class with CSS variable assignments - // Single arg: createTheme({ color: { brand: 'blue' } }) -> returns [themeClass, vars] - // Two args: createTheme(contract, values) -> returns themeClass - let collector_theme = collector.clone(); - let create_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let first_arg = args.get_or_undefined(0); - let second_arg = args.get_or_undefined(1); - - // Check if it's single-arg (values only) or two-arg (contract, values) - let is_single_arg = second_arg.is_undefined(); - - let id = next_style_id(&collector_theme); - - if is_single_arg { - // Single arg: createTheme({ color: { brand: 'blue' } }) - // Returns [themeClass, vars] where vars has var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - - // Transform values to var() references and collect CSS variables - let vars_obj = transform_theme_to_vars( - first_arg, - ctx, - &id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - let vars_object_json = js_value_to_json(&vars_obj, ctx); - - // Store the theme entry with vars object - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: Some(vars_object_json), - vars_name: None, // Will be set during remapping - class_name: String::new(), // Will be set during remapping - }, - ); - - // Return [themeId, varsObject] as an array - let result_array = boa_engine::object::builtins::JsArray::new(ctx)?; - result_array.push(JsValue::from(js_string!(id.clone())), ctx)?; - result_array.push(vars_obj, ctx)?; - - Ok(JsValue::from(result_array)) - } else { - // Two args: createTheme(contract, values) - // Returns just the themeClass - let mut css_vars = SmallVec::new(); - extract_theme_vars(first_arg, second_arg, ctx, &mut css_vars, &mut Vec::new()); - - // Store the theme entry - collector_theme.borrow_mut().styles.themes.insert( - id.clone(), - ThemeEntry { - css_vars, - exported: false, - vars_object_json: None, - vars_name: None, - class_name: String::new(), // Will be set during remapping - }, - ); - - Ok(JsValue::from(js_string!(id))) - } - }) +/// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a +/// generated custom property, registered with `@property` when declared +fn create_var( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let first = args.get_or_undefined(0); + let (declaration, debug_id) = match first.as_object() { + Some(declaration) => (Some(declaration), args.get_or_undefined(1)), + None => (None, first), }; + let name = format!( + "--{}", + collector.borrow_mut().identifier(js_str(debug_id), "var") + ); + if let Some(declaration) = declaration { + let rule = property_rule(&name, &declaration, context)?; + collector.borrow_mut().styles.property_rules.push(rule); + } + Ok(js_string!(format!("var({name})")).into()) +} - // layer() function - let collector_layer = collector.clone(); - let layer_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let id = next_style_id(&collector_layer); - let name = args - .get_or_undefined(0) - .to_string(ctx)? - .to_std_string_escaped(); - collector_layer - .borrow_mut() - .styles - .layers - .insert(id, (name.clone(), false)); - Ok(JsValue::from(js_string!(name))) - }) +fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { + let syntax = declaration.get(js_string!("syntax"), context)?; + let syntax = match array_items(&syntax, context)? { + Some(items) => items + .iter() + .map(|item| to_text(item, context)) + .collect::>>()? + .join(" | "), + None => to_text(&syntax, context)?, }; + let inherits = declaration + .get(js_string!("inherits"), context)? + .to_boolean(); + let mut rule = format!("@property {name}{{syntax:\"{syntax}\";inherits:{inherits}"); + let initial_value = declaration.get(js_string!("initialValue"), context)?; + if !initial_value.is_null_or_undefined() { + rule.push_str(";initial-value:"); + rule.push_str(&to_text(&initial_value, context)?); + } + rule.push('}'); + Ok(rule) +} - // createContainer() function - let collector_container = collector.clone(); - let create_container_fn = unsafe { - NativeFunction::from_closure(move |_this, _args, _ctx| { - let id = next_style_id(&collector_container); - let container_name = format!( - "__container_{}__", - collector_container.borrow().styles.containers.len() - ); - collector_container - .borrow_mut() - .styles - .containers - .insert(id, (container_name.clone(), false)); - Ok(JsValue::from(js_string!(container_name))) - }) +/// `fallbackVar(...values)`: each `var()` falls back to the values after it +fn fallback_var(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let mut result = String::new(); + for (index, value) in args.iter().rev().enumerate() { + let value = to_text(value, context)?; + result = match value.strip_suffix(')') { + Some(head) if index > 0 => format!("{head}, {result})"), + _ => value, + }; + } + Ok(js_string!(result).into()) +} + +fn create_container(collector: &StyleCollector, args: &[JsValue]) -> JsValue { + let name = collector + .borrow_mut() + .identifier(js_str(args.get_or_undefined(0)), "container"); + js_string!(name).into() +} + +/// `(parent, name argument)` of `layer(options?, debugId?)` and +/// `globalLayer(options?, name)` +fn layer_args(args: &[JsValue], context: &mut Context) -> JsResult<(Option, JsValue)> { + let first = args.get_or_undefined(0); + Ok(match first.as_object() { + Some(options) => ( + js_str(&options.get(js_string!("parent"), context)?), + args.get_or_undefined(1).clone(), + ), + None => (None, first.clone()), + }) +} + +fn declare_layer(collector: &StyleCollector, parent: Option, name: String) -> JsValue { + let name = match parent { + Some(parent) => format!("{parent}.{name}"), + None => name, }; + collector.borrow_mut().styles.layers.push(name.clone()); + js_string!(name).into() +} + +fn layer(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsResult { + let (parent, debug_id) = layer_args(args, context)?; + let name = collector + .borrow_mut() + .identifier(js_str(&debug_id), "layer"); + Ok(declare_layer(collector, parent, name)) +} + +fn global_layer( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let (parent, name) = layer_args(args, context)?; + let name = to_text(&name, context)?; + Ok(declare_layer(collector, parent, name)) +} + +type Leaf<'a> = dyn FnMut(&JsValue, &[String], &mut Context) -> JsResult + 'a; + +/// vanilla-extract's `walkObject`: `tokens` with every string, number or empty +/// leaf replaced by `leaf(value, path)`; other values are skipped +fn walk_object( + tokens: &JsValue, + context: &mut Context, + path: &mut Vec, + leaf: &mut Leaf<'_>, +) -> JsResult { + let walked = ObjectInitializer::new(context).build(); + if let Some(object) = tokens.as_object() { + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + path.push(name.clone()); + let mapped = if value.is_string() || value.is_number() || value.is_null_or_undefined() { + Some(leaf(&value, path, context)?) + } else if value.as_object().is_some_and(|object| !object.is_array()) { + Some(walk_object(&value, context, path, leaf)?) + } else { + None + }; + path.pop(); + if let Some(mapped) = mapped { + walked.set(js_string!(name), mapped, false, context)?; + } + } + } + Ok(walked.into()) +} - // createGlobalTheme() function - let collector_global_theme = collector; - let create_global_theme_fn = unsafe { - NativeFunction::from_closure(move |_this, args, ctx| { - let placeholder_id = next_global_theme_id(&collector_global_theme); - let selector = args - .get_or_undefined(0) - .to_string(ctx) - .map_or_else(|_| ":root".to_string(), |s| s.to_std_string_escaped()); - let theme_obj = args.get_or_undefined(1); - - // Collect CSS variables and build new object with var() references - let mut css_vars = SmallVec::new(); - let mut var_counter = 0usize; - let result_obj = transform_theme_to_vars( - theme_obj, - ctx, - &placeholder_id, - &mut css_vars, - &mut var_counter, - &mut Vec::new(), - ); - - // Serialize the result object to JSON for code generation - let vars_object_json = js_value_to_json(&result_obj, ctx); - - // Store the collected CSS variables and vars object - collector_global_theme +/// A contract of `tokens`' shape whose leaves read generated custom properties +fn contract_of( + collector: &StyleCollector, + tokens: &JsValue, + context: &mut Context, +) -> JsResult { + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |_value, path, _context| { + let name = collector .borrow_mut() - .styles - .global_themes - .insert( - placeholder_id, - GlobalThemeEntry { - selector, - css_vars, - vars_object_json, - exported: false, - }, - ); - - Ok(result_obj) - }) - }; + .identifier(Some(path.join("-")), "var"); + Ok(js_string!(format!("var(--{name})")).into()) + }, + ) +} - // Build the mock object - let mut ve_builder = ObjectInitializer::new(context); - ve_builder.function(style_fn, js_string!("style"), 1); - ve_builder.function(global_style_fn, js_string!("globalStyle"), 2); - ve_builder.function(keyframes_fn, js_string!("keyframes"), 1); - ve_builder.function(create_var_fn, js_string!("createVar"), 0); - ve_builder.function(font_face_fn, js_string!("fontFace"), 1); - ve_builder.function(style_variants_fn, js_string!("styleVariants"), 1); - ve_builder.function(fallback_var_fn, js_string!("fallbackVar"), 2); - ve_builder.function(create_theme_fn, js_string!("createTheme"), 1); - ve_builder.function( - create_theme_contract_fn, - js_string!("createThemeContract"), - 1, - ); - ve_builder.function(layer_fn, js_string!("layer"), 1); - ve_builder.function(create_container_fn, js_string!("createContainer"), 0); - ve_builder.function(create_global_theme_fn, js_string!("createGlobalTheme"), 2); +fn create_theme_contract( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + contract_of(collector, args.get_or_undefined(0), context) +} - let ve_obj = ve_builder.build(); +/// `createGlobalThemeContract(tokens, map?)`: leaves name their custom property, +/// or `map(value, path)` does +fn create_global_theme_contract( + _this: &JsValue, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let map = args.get_or_undefined(1).as_callable(); + walk_object( + args.get_or_undefined(0), + context, + &mut Vec::new(), + &mut |value, path, context| { + let name = match &map { + Some(map) => { + let path = JsArray::from_iter( + path.iter().map(|key| js_string!(key.as_str()).into()), + context, + ); + map.call( + &JsValue::undefined(), + &[value.clone(), path.into()], + context, + )? + } + None => value.clone(), + }; + let name = to_text(&name, context)?; + Ok(js_string!(format!( + "var(--{})", + name.strip_prefix("--").unwrap_or(&name) + )) + .into()) + }, + ) +} - // Register as global __vanilla_extract__ - context - .register_global_property(js_string!("__vanilla_extract__"), ve_obj, Attribute::all()) - .map_err(|e| format!("Failed to register __vanilla_extract__: {e}"))?; +/// `(custom property, value)` for every leaf of `tokens`, read from the same +/// path of `contract` +fn assign_vars( + contract: &JsValue, + tokens: &JsValue, + context: &mut Context, +) -> JsResult> { + let mut vars = Vec::new(); + walk_object( + tokens, + context, + &mut Vec::new(), + &mut |value, path, context| { + let mut target = contract.clone(); + for key in path { + target = match target.as_object() { + Some(object) => object.get(js_string!(key.as_str()), context)?, + None => JsValue::undefined(), + }; + } + if let Some(reference) = js_str(&target) { + vars.push((var_name(&reference).to_string(), to_text(value, context)?)); + } + Ok(JsValue::undefined()) + }, + )?; + Ok(vars) +} + +fn assign_vars_api(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let assigned = ObjectInitializer::new(context).build(); + for (name, value) in assign_vars(args.get_or_undefined(0), args.get_or_undefined(1), context)? { + assigned.set( + js_string!(format!("var({name})")), + js_string!(value), + false, + context, + )?; + } + Ok(assigned.into()) +} + +/// `tokens` without its `@layer`, and the layer it names +fn split_layer(tokens: &JsValue, context: &mut Context) -> JsResult<(JsValue, Option)> { + let Some(object) = tokens.as_object() else { + return Ok((tokens.clone(), None)); + }; + let mut layer = None; + let rest = ObjectInitializer::new(context).build(); + for (key, name) in own_keys(&object, context)? { + let value = object.get(key, context)?; + if name == "@layer" { + layer = Some(to_text(&value, context)?); + } else { + rest.set(js_string!(name), value, false, context)?; + } + } + Ok((rest.into(), layer)) +} +/// Declare on `selector` the values `tokens` gives the variables of `contract` +fn declare_theme( + collector: &StyleCollector, + selector: String, + contract: &JsValue, + tokens: &JsValue, + layer: Option, + context: &mut Context, +) -> JsResult<()> { + let vars = assign_vars(contract, tokens, context)?; + if vars.is_empty() { + return Ok(()); + } + let mut declarations: Vec = layer + .iter() + .map(|layer| format!("\"@layer\":{}", json_string(layer))) + .collect(); + for (name, value) in &vars { + declarations.push(format!("{}:{}", json_string(name), json_string(value))); + } + collector + .borrow_mut() + .styles + .global_styles + .push((selector, format!("{{{}}}", declarations.join(",")))); Ok(()) } +/// `createTheme(tokens, debugId?)` -> `[class, vars]`, or +/// `createTheme(contract, tokens, debugId?)` -> class +fn create_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let contract_given = args.get_or_undefined(1).is_object(); + let (tokens, debug_id) = if contract_given { + (args.get_or_undefined(1), args.get_or_undefined(2)) + } else { + (args.get_or_undefined(0), args.get_or_undefined(1)) + }; + let class_name = collector.borrow_mut().identifier(js_str(debug_id), "theme"); + let (tokens, layer) = split_layer(tokens, context)?; + let contract = if contract_given { + args.get_or_undefined(0).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme( + collector, + format!(".{class_name}"), + &contract, + &tokens, + layer, + context, + )?; + let class_name = JsValue::from(js_string!(class_name)); + Ok(if contract_given { + class_name + } else { + JsArray::from_iter([class_name, contract], context).into() + }) +} + +/// `createGlobalTheme(selector, tokens)` -> vars, or +/// `createGlobalTheme(selector, contract, tokens)` +fn create_global_theme( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let selector = to_text(args.get_or_undefined(0), context)?; + let contract_given = args.get_or_undefined(2).to_boolean(); + let (tokens, layer) = split_layer( + args.get_or_undefined(if contract_given { 2 } else { 1 }), + context, + )?; + let contract = if contract_given { + args.get_or_undefined(1).clone() + } else { + contract_of(collector, &tokens, context)? + }; + declare_theme(collector, selector, &contract, &tokens, layer, context)?; + Ok(if contract_given { + JsValue::undefined() + } else { + contract + }) +} + /// Placeholders interpolated into a selector end inside a JSON key. fn in_json_key(rest: &str) -> bool { rest.find('"') @@ -1229,7 +1152,7 @@ impl CollectedStyles { ) -> String { replace_placeholders(source, |token, rest| { Some(match self.references.get(token)? { - Reference::Style { class_name, .. } | Reference::Class(class_name) => { + Reference::Style { class_name, .. } => { if selector(rest) { format!(".{class_name}") } else { @@ -1237,7 +1160,6 @@ impl CollectedStyles { } } Reference::Keyframes(name) => keyframes_names.get(name)?.clone(), - Reference::Font(family) => family.clone(), }) }) .unwrap_or_else(|| source.to_string()) @@ -1251,14 +1173,8 @@ impl CollectedStyles { fn texts(&self) -> impl Iterator { self.styles .values() + .chain(self.keyframes.values()) .map(|entry| entry.json.as_str()) - .chain(self.keyframes.values().map(|entry| entry.json.as_str())) - .chain( - self.style_variants - .values() - .flat_map(|(variants, _)| variants.values()) - .map(|variant| variant.styles_json.as_str()), - ) .chain( self.global_styles .iter() @@ -1285,7 +1201,7 @@ impl CollectedStyles { .into_iter() .filter_map(|reference| match reference { Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())), - _ => None, + Reference::Keyframes(_) => None, }) .collect() } @@ -1299,7 +1215,7 @@ pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet { .into_iter() .filter_map(|reference| match reference { Reference::Keyframes(name) => Some(name.clone()), - _ => None, + Reference::Style { .. } => None, }) .collect() } @@ -1328,58 +1244,87 @@ pub fn keyframes_to_code( output } +/// The members of a JSON object, without its braces fn inner_json(json: &str) -> &str { json.trim() - .trim_start_matches('{') - .trim_end_matches('}') + .strip_prefix('{') + .and_then(|json| json.strip_suffix('}')) + .unwrap_or_default() .trim() } -/// `css(...)` of `own` composed onto `bases`, plus the unique classes that let -/// selectors target them +/// Styles `entry` composes, transitively and in order, each once +fn collect_bases<'a>( + collected: &'a CollectedStyles, + entry: &'a StyleEntry, + seen: &mut FxHashSet<&'a str>, + bases: &mut Vec<(&'a str, &'a StyleEntry)>, +) { + for base in &entry.bases { + if seen.insert(base.as_str()) + && let Some(base_entry) = collected.styles.get(base) + { + collect_bases(collected, base_entry, seen, bases); + bases.push((base.as_str(), base_entry)); + } + } +} + +/// `css(...)` of the style `name` composed onto its bases, plus the classes it +/// composes and the unique classes that let selectors target them fn composed_css( collected: &CollectedStyles, keyframes_names: &FxHashMap, referenced_classes: &FxHashMap<&str, &str>, - bases: &[&str], - own_name: Option<&str>, - own_json: &str, + name: &str, + entry: &StyleEntry, ) -> String { - let mut parts = Vec::with_capacity(bases.len() + 1); + let mut seen = FxHashSet::default(); + seen.insert(name); + let mut bases = Vec::new(); + collect_bases(collected, entry, &mut seen, &mut bases); + + let mut rules = Vec::with_capacity(bases.len() + 1); let mut classes = Vec::new(); - for base in bases { - if let Some(entry) = collected.styles.get(*base) { - let json = collected.resolve_json(&entry.json, keyframes_names); - let inner = inner_json(&json); - if !inner.is_empty() { - parts.push(inner.to_string()); - } + for (base_name, base) in &bases { + let json = collected.resolve_json(&base.json, keyframes_names); + let inner = inner_json(&json); + if !inner.is_empty() { + rules.push(inner.to_string()); } - classes.extend(referenced_classes.get(base)); + classes.extend(base.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(base_name).copied()); } - let own = collected.resolve_json(own_json, keyframes_names); + classes.extend(entry.classes.iter().map(String::as_str)); + classes.extend(referenced_classes.get(name).copied()); + + let own = collected.resolve_json(&entry.json, keyframes_names); let css = if bases.is_empty() { format!("css({own})") } else { let inner = inner_json(&own); if !inner.is_empty() { - parts.push(inner.to_string()); + rules.push(inner.to_string()); } - format!("css({{{}}})", parts.join(",")) + format!("css({{{}}})", rules.join(",")) }; - classes.extend(own_name.and_then(|name| referenced_classes.get(name))); if classes.is_empty() { css } else { - let mut unique = String::new(); - for class in classes { - unique.push(' '); - unique.push_str(class); - } - format!("{css} + \"{unique}\"") + format!("{css} + \" {}\"", classes.join(" ")) } } +fn sorted(entries: &FxHashMap) -> Vec<(&String, &StyleEntry)> { + let mut entries: Vec<_> = entries.iter().collect(); + entries.sort_by_key(|(name, _)| *name); + entries +} + +const fn export_prefix(exported: bool) -> &'static str { + if exported { "export " } else { "" } +} + /// Convert collected styles to code, with keyframes references replaced by /// `keyframes_names` (`variable_name` -> generated name) pub fn collected_styles_to_code_with_keyframes( @@ -1387,2167 +1332,447 @@ pub fn collected_styles_to_code_with_keyframes( package: &str, keyframes_names: &FxHashMap, ) -> String { - let mut code_parts = Vec::with_capacity(collected.styles.len() + 4); + let mut code = Vec::new(); - // Generate import statement let mut imports = Vec::new(); - if !collected.styles.is_empty() - || !collected.style_variants.is_empty() - || !collected.themes.is_empty() - { + if !collected.styles.is_empty() { imports.push("css"); } - if !collected.global_styles.is_empty() - || !collected.font_faces.is_empty() - || !collected.global_themes.is_empty() + if !(collected.global_styles.is_empty() + && collected.font_faces.is_empty() + && collected.layers.is_empty() + && collected.property_rules.is_empty()) { imports.push("globalCss"); } if !collected.keyframes.is_empty() { imports.push("keyframes"); } - if !imports.is_empty() { - code_parts.push(format!( - "import {{ {} }} from '{}'", - imports.join(", "), - package + code.push(format!( + "import {{ {} }} from '{package}'", + imports.join(", ") )); } - let referenced_classes = collected.referenced_classes(); - let mut styles: Vec<_> = collected.styles.iter().collect(); - styles.sort_by_key(|(name, _)| *name); - - for (name, entry) in styles { - let prefix = if entry.exported { "export " } else { "" }; - let bases: Vec<&str> = entry.bases.iter().map(String::as_str).collect(); - let css = composed_css( - collected, - keyframes_names, - &referenced_classes, - &bases, - Some(name), - &entry.json, - ); - code_parts.push(format!("{prefix}const {name} = {css}")); - } - // Generate createTheme exports (class name and optionally vars object) - // Note: CSS variables are added to global_styles during remapping - let mut themes: Vec<_> = collected.themes.iter().collect(); - themes.sort_by_key(|(name, _)| *name); - for (name, entry) in themes { - let prefix = if entry.exported { "export " } else { "" }; - if let Some(vars_name) = &entry.vars_name { - if let Some(vars_json) = &entry.vars_object_json { - code_parts.push(format!( - "{}const [{}, {}] = [\"{}\", {}]", - prefix, name, vars_name, entry.class_name, vars_json - )); - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); - } - } else { - code_parts.push(format!( - "{}const {} = \"{}\"", - prefix, name, entry.class_name - )); + // Declaring every layer up front fixes their order the way the calls did. + let mut layers: Vec<&str> = Vec::new(); + for layer in &collected.layers { + if !layers.contains(&layer.as_str()) { + layers.push(layer); } } - - append_non_style_code( - collected, - keyframes_names, - &referenced_classes, - &mut code_parts, - ); - - code_parts.join("\n") -} - -/// Append non-style code parts (globalCss, keyframes, fontFaces, etc.) -fn append_non_style_code( - collected: &CollectedStyles, - keyframes_names: &FxHashMap, - referenced_classes: &FxHashMap<&str, &str>, - code_parts: &mut Vec, -) { - for (selector, json) in &collected.global_styles { - let selector = collected.resolve(selector, keyframes_names, |_| true); - let json = collected.resolve_json(json, keyframes_names); - code_parts.push(format!( - "globalCss({{ {}: {json} }})", - serde_json::Value::String(selector) - )); + if !layers.is_empty() { + code.push(format!("globalCss`@layer {};`", layers.join(","))); } - - // Generate @font-face rules - let mut font_faces_sorted: Vec<_> = collected.font_faces.iter().collect(); - font_faces_sorted.sort_by_key(|(name, _)| *name); - for (_name, (json, font_family, _exported)) in &font_faces_sorted { - let props = parse_font_face_json(json); - let props_str = props - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join(", "); - let code = if props_str.is_empty() { - format!("globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\" }}] }})") - } else { - format!( - "globalCss({{ fontFaces: [{{ fontFamily: \"{font_family}\", {props_str} }}] }})" - ) - }; - code_parts.push(code); - } - - // Generate createGlobalTheme CSS variables - let mut global_themes_sorted: Vec<_> = collected.global_themes.iter().collect(); - global_themes_sorted.sort_by_key(|(name, _)| *name); - for (_name, entry) in &global_themes_sorted { - if !entry.css_vars.is_empty() { - let vars_str = entry - .css_vars - .iter() - .map(|(var_name, value)| format!("\"{var_name}\": \"{value}\"")) - .collect::>() - .join(", "); - code_parts.push(format!( - "globalCss({{ \"{}\": {{ {} }} }})", - entry.selector, vars_str - )); - } + for rule in &collected.property_rules { + code.push(format!("globalCss`{rule}`")); } - // Generate keyframes - let mut keyframes: Vec<_> = collected.keyframes.iter().collect(); - keyframes.sort_by_key(|(name, _)| *name); - for (name, entry) in keyframes { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = keyframes({})", - prefix, - name, + for (name, entry) in sorted(&collected.keyframes) { + code.push(format!( + "{}const {name} = keyframes({})", + export_prefix(entry.exported), collected.resolve_json(&entry.json, keyframes_names) )); } - let mut variants: Vec<_> = collected.style_variants.iter().collect(); - variants.sort_by_key(|(name, _)| *name); - for (name, (variant_map, exported)) in variants { - let mut variant_entries: Vec<_> = variant_map.iter().collect(); - variant_entries.sort_by_key(|(k, _)| *k); - let mut object_parts = Vec::new(); - for (variant_key, variant) in variant_entries { - let bases: Vec<&str> = variant.base.iter().map(String::as_str).collect(); - let value = composed_css( - collected, - keyframes_names, - referenced_classes, - &bases, - None, - &variant.styles_json, - ); - object_parts.push(format!(" {variant_key}: {value}")); - } - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {{\n{}\n}}", - prefix, - name, - object_parts.join(",\n") + let referenced_classes = collected.referenced_classes(); + for (name, entry) in sorted(&collected.styles) { + code.push(format!( + "{}const {name} = {}", + export_prefix(entry.exported), + composed_css(collected, keyframes_names, &referenced_classes, name, entry) )); } - // Generate createVar declarations - let mut vars: Vec<_> = collected.vars.iter().collect(); - vars.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in vars { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - - // Generate fontFace declarations (reuse the name-sorted vec built above) - for (name, (_, font_family, exported)) in &font_faces_sorted { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{font_family}\"")); - } - - // Generate createContainer declarations - let mut containers: Vec<_> = collected.containers.iter().collect(); - containers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in containers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - - // Generate layer declarations - let mut layers: Vec<_> = collected.layers.iter().collect(); - layers.sort_by_key(|(name, _)| *name); - for (name, (value, exported)) in layers { - let prefix = if *exported { "export " } else { "" }; - code_parts.push(format!("{prefix}const {name} = \"{value}\"")); - } - // Generate createGlobalTheme vars object declarations - for (name, entry) in &global_themes_sorted { - let prefix = if entry.exported { "export " } else { "" }; - code_parts.push(format!( - "{}const {} = {}", - prefix, name, entry.vars_object_json + for (selector, json) in &collected.global_styles { + let selector = collected.resolve(selector, keyframes_names, |_| true); + code.push(format!( + "globalCss({{ {}: {} }})", + json_string(&selector), + collected.resolve_json(json, keyframes_names) )); } - - // Generate constant exports - let mut constants: Vec<_> = collected.constant_exports.iter().collect(); - constants.sort_by_key(|(name, _)| *name); - for (name, value) in constants { - code_parts.push(format!("export const {name} = {value}")); - } -} - -/// Convert collected styles to code that can be processed by existing extract logic -#[cfg(test)] -pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String { - collected_styles_to_code_with_keyframes(collected, package, &FxHashMap::default()) -} - -/// Parse a styleVariants object and extract variant info -fn parse_style_variants( - variants_obj: &JsValue, - context: &mut Context, -) -> FxHashMap { - let mut result = FxHashMap::default(); - - if let Some(obj) = variants_obj.as_object() { - // Get the object's own property keys - if let Ok(keys) = obj.own_property_keys(context) { - for key in keys { - // Convert PropertyKey to string - let key_name = match &key { - boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(), - boa_engine::property::PropertyKey::Symbol(_) => continue, - boa_engine::property::PropertyKey::Index(i) => i.get().to_string(), - }; - - if let Ok(value) = obj.get(key.clone(), context) { - let variant = parse_single_variant(&value, context); - result.insert(key_name, variant); - } - } - } - } - - result -} - -/// Parse a single variant value (either style object or [base, styleObj] array) -fn parse_single_variant(value: &JsValue, context: &mut Context) -> StyleVariant { - // Check if value is an array by checking if it has a numeric "length" property - if let Some(obj) = value.as_object() - && let Ok(length_val) = obj.get(js_string!("length"), context) - && let Some(len) = length_val.as_number() - { - let len = len as u32; - if len >= 2 { - // It's an array with at least 2 elements - if let Ok(first) = obj.get(0, context) - && let Some(base_str) = first.as_string() - { - let base_class = base_str.to_std_string_escaped(); - // Check if it looks like a placeholder (__style_N__) or class name - if base_class.starts_with("__style_") || !base_class.contains('{') { - // Get the style object (second element) - if let Ok(style_obj) = obj.get(1, context) { - let json = style_to_json(&style_obj, context); - return StyleVariant { - base: Some(base_class), - styles_json: json, - }; - } - } - } - } + if !collected.font_faces.is_empty() { + code.push(format!( + "globalCss({{ fontFaces: [{}] }})", + collected.font_faces.join(", ") + )); } - // Not an array or not composition - treat as plain style object - StyleVariant { - base: None, - styles_json: style_to_json(value, context), + for (name, value) in &collected.constant_exports { + code.push(format!("export const {name} = {value}")); } + code.join("\n") } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; - use crate::utils::is_vanilla_extract_file; - use smallvec::smallvec; + use css::file_map::reset_file_map; + use serial_test::serial; - #[test] - fn test_preprocess_typescript() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // The result should have destructuring from __vanilla_extract__ and no export keyword - assert!( - result.contains("__vanilla_extract__"), - "Expected __vanilla_extract__ but got: {result}" - ); - assert!( - !result.contains("export const"), - "Should not contain 'export const': {result}" - ); - } + const PACKAGE: &str = "@devup-ui/react"; - #[test] - fn test_preprocess_typescript_strips_types() { - let code = r#"import { style } from '@devup-ui/react' -interface Props { - color: string; -} -export const container = style({ background: "red" })"#; - let result = preprocess_typescript(code, "@devup-ui/react"); - // TypeScript interface should be stripped - assert!( - !result.contains("interface"), - "Should not contain interface: {result}" - ); + fn generate_with(code: &str, keyframes_names: &[(&str, &str)]) -> String { + reset_file_map(); + let collected = execute_vanilla_extract( + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '{PACKAGE}'\n{code}"), + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let keyframes_names = keyframes_names + .iter() + .map(|(name, generated)| (name.to_string(), generated.to_string())) + .collect(); + collected_styles_to_code_with_keyframes(&collected, PACKAGE, &keyframes_names) } - #[test] - fn test_execute_vanilla_extract_style() { - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(!result.styles.is_empty()); + fn generate(code: &str) -> String { + generate_with(code, &[]) } #[test] - fn test_execute_vanilla_extract_style_multiple() { - // Test with multiple style exports - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 }) -export const button = style({ color: "blue" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!( - result.styles.len() >= 2, - "Expected at least 2 styles but got: {result:?}" + #[serial] + fn test_names_follow_the_values_variables_hold() { + assert_eq!( + generate_with( + "export const sizes = { sm: style({ padding: 1 }), lg: style({ padding: 2 }) } +export const box = style({ color: 'red' }) +const spin = keyframes({ to: { opacity: 1 } }) +export const alias = box +export const animated = style({ animationName: spin })", + &[("spin", "k")] + ), + r#"import { css, keyframes } from '@devup-ui/react' +const spin = keyframes({"to":{"opacity":1}}) +const _ve0 = css({"padding":"1px"}) +const _ve1 = css({"padding":"2px"}) +export const animated = css({"animationName":"k"}) +export const box = css({"color":"red"}) +export const sizes = { "sm": _ve0, "lg": _ve1 } +export const alias = box"# ); - } - - #[test] - fn test_collected_styles_to_code_multiple() { - // Test the full flow: execute → collected_styles_to_code with multiple styles - let code = r#"import { style } from '@devup-ui/react' -export const container = style({ background: "red", padding: 16 })"#; - let collected = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - let generated = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!( - !generated.is_empty(), - "Expected non-empty generated code. Collected: {collected:?}" + assert_eq!( + generate("const _ve0 = 1\nexport const list = [style({ color: 'red' })]"), + r#"import { css } from '@devup-ui/react' +const _ve0_ = css({"color":"red"}) +export const list = [_ve0_]"# ); - assert!( - generated.contains("css("), - "Expected css() call in generated code: {generated}" + assert_eq!( + generate("export const animated = style({ animationName: keyframes({}) })"), + r#"import { css, keyframes } from '@devup-ui/react' +const _ve0 = keyframes({}) +export const animated = css({"animationName":"__style_0__"})"# ); } #[test] - fn test_full_flow_multiline() { - // Test exactly what lib.rs extract function does (with already-transformed imports) - let code = r"import { style } from '@devup-ui/react' -export const hello = style({ - cursor: 'pointer', - fontSize: 32, - paddingTop: '28px', - paddingBottom: '28px', -}) -export const text = style({ - color: 'var(--text)', -}) -"; - let package = "@devup-ui/react"; - let filename = "styles.css.ts"; - - // This is exactly what lib.rs does - assert!( - is_vanilla_extract_file(filename), - "Should be vanilla-extract file" + #[serial] + fn test_exported_values_become_code() { + assert_eq!( + generate( + "const box = style({ color: 'red' }) +export const text = 'plain \"quoted\"' +export const template = `${box} extra \\`tick\\` \\${x} back\\\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { a: { b: [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = Object.create(null) +export const { destructured } = { destructured: () => 1 } +export declare const typeOnly: string +export function makeBox() { return box } +export type Box = string +export const symbol = Symbol('x')" + ), + r#"import { css } from '@devup-ui/react' +const box = css({"color":"red"}) +export const text = "plain \"quoted\"" +export const template = `${box} extra \`tick\` \${x} back\\slash __open` +export const nothing = undefined +export const empty = null +export const flag = true +export const count = 1.5 +export const list = [box, 2] +export const nested = { "a": { "b": [box] } } +export const helper = (x) => x +export const fns = [() => 1] +export const date = new Date(0) +export const cyclic = (() => { const o = {}; o.self = o; return o })() +export const bare = {} +export const symbol = Symbol('x')"# + ); + } + + #[test] + #[serial] + fn test_style_variants_are_styles() { + assert_eq!( + generate( + "export const tone = styleVariants({ primary: { color: 'red' }, 0: { color: 'blue' }, [Symbol('s')]: { color: 'green' } }) +export const space = styleVariants({ sm: 4 }, (padding, key) => ({ padding, content: key })) +export const none = styleVariants(undefined) +export const combined = style([tone.primary, 'external', [{ margin: 1 }]]) +globalStyle(`${tone.primary} > span`, { fontWeight: 700 })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +const _ve0 = css({"color":"blue"}) +const _ve1 = css({"color":"red"}) + " f0__ve1" +const _ve2 = css({"padding":"4px","content":"sm"}) +export const combined = css({"color":"red","margin":"1px"}) + " f0__ve1 external" +globalCss({ ".f0__ve1 > span": {"fontWeight":700} }) +export const tone = { "0": _ve0, "primary": _ve1 } +export const space = { "sm": _ve2 } +export const none = {}"# ); - - let result = execute_vanilla_extract(code, package, filename); - match result { - Ok(collected) => { - assert!( - !collected.styles.is_empty(), - "Styles should not be empty. Collected: {collected:?}" - ); - let generated = super::collected_styles_to_code(&collected, package); - assert!( - !generated.is_empty(), - "Generated code should not be empty. Generated: {generated}" - ); - println!("Generated code:\n{generated}"); - } - Err(e) => { - panic!("execute_vanilla_extract failed: {e}"); - } - } } #[test] - fn test_execute_vanilla_extract_global_style() { - let code = r#"import { globalStyle } from '@devup-ui/react' -globalStyle("body", { margin: 0, padding: 0 })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert_eq!(result.global_styles.len(), 1); - assert_eq!(result.global_styles[0].0, "body"); + #[serial] + fn test_composition_is_transitive() { + assert_eq!( + generate( + "const a = style({ color: 'red' }) +const b = style([a, { margin: 2 }]) +export const c = style([b, { padding: 3 }]) +const e = style({}) +export const f = style([e, a, '__style_99__']) +export const g = style(5) +const o = {}; o.self = o +export const h = style(o) +export const hover = style({ selectors: { [`${a}:hover &`]: { color: 'blue' } } })" + ), + r#"import { css } from '@devup-ui/react' +const a = css({"color":"red"}) + " f0_a" +const b = css({"color":"red","margin":"2px"}) + " f0_a" +export const c = css({"color":"red","margin":"2px","padding":"3px"}) + " f0_a" +const e = css({}) +export const f = css({"color":"red"}) + " f0_a" +export const g = css({}) +export const h = css({}) +export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# + ); } #[test] - fn test_collected_styles_to_code() { - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "container".to_string(), - StyleEntry { - json: r#"{"background":"red"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, + #[serial] + fn test_font_faces() { + assert_eq!( + generate( + "export const body = fontFace({ src: 'local(a)' }, 'Body Font') +export const icons = fontFace([{ src: 'local(b)' }, { src: 'local(c)', fontWeight: 700 }]) +fontFace({}) +const o = {}; o.self = o +fontFace(o) +globalFontFace('Inter', { src: 'local(Inter)' })" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss({ fontFaces: [{"src":"local(a)","fontFamily":"Body_Font-0-0"}, {"src":"local(b)","fontFamily":"font-0-1"}, {"src":"local(c)","fontWeight":700,"fontFamily":"font-0-1"}, {"fontFamily":"font-0-2"}, {"fontFamily":"font-0-3"}, {"src":"local(Inter)","fontFamily":"Inter"}] }) +export const body = "Body_Font-0-0" +export const icons = "font-0-1""# ); - - let code = collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { css } from '@devup-ui/react'")); - assert!(code.contains(r#"export const container = css({"background":"red"})"#)); } #[test] - fn test_execute_vanilla_extract_with_variable() { - // Test that variables are evaluated at execution time - let code = r#"import { style } from '@devup-ui/react' -const primaryColor = "blue"; -const spacing = 16; -export const button = style({ background: primaryColor, padding: spacing })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("button")); - let entry = &result.styles["button"]; - // The variable values should be resolved - assert!( - entry.json.contains("blue"), - "Expected 'blue' in JSON: {}", - entry.json - ); - assert!( - entry.json.contains("16"), - "Expected '16' in JSON: {}", - entry.json + #[serial] + fn test_vars_follow_vanilla_extract() { + assert_eq!( + generate( + "export const plain = createVar() +export const named = createVar('9 lives') +export const typed = createVar({ syntax: '', inherits: false, initialValue: '0px' }, 'size') +export const union = createVar({ syntax: ['', ''], inherits: true }) +export const empty = createVar('') +export const fallback = fallbackVar(plain, named, 'red') +export const invalid = fallbackVar('red', 'blue') +export const none = fallbackVar() +export const box = style({ vars: { [plain]: '1px', '--raw': 2 }, width: plain, padding: 0, zIndex: 2, margin: [1, 2] })" + ), + r#"import { css, globalCss } from '@devup-ui/react' +globalCss`@property --size-0-2{syntax:"";inherits:false;initial-value:0px}` +globalCss`@property --var-0-3{syntax:" | ";inherits:true}` +export const box = css({"vars":{"--var-0-0":"1px","--raw":2},"width":"var(--var-0-0)","padding":0,"zIndex":2,"margin":[1,2]}) +export const plain = "var(--var-0-0)" +export const named = "var(--_9_lives-0-1)" +export const typed = "var(--size-0-2)" +export const union = "var(--var-0-3)" +export const empty = "var(--var-0-4)" +export const fallback = "var(--var-0-0, var(--_9_lives-0-1, red))" +export const invalid = "red" +export const none = """# ); } #[test] - fn test_execute_vanilla_extract_with_computed_value() { - // Test computed values - let code = r"import { style } from '@devup-ui/react' -const base = 8; -export const box = style({ padding: base * 2, margin: base / 2 })"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("box")); - let entry = &result.styles["box"]; - assert!( - entry.json.contains("16"), - "Expected padding 16 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json + #[serial] + fn test_layers_and_containers() { + assert_eq!( + generate( + "export const reset = layer() +export const base = layer('base') +export const child = layer({ parent: base }, 'child') +export const orphan = layer({}) +export const named = globalLayer('utilities') +export const nested = globalLayer({ parent: named }, 'x') +export const again = globalLayer('utilities') +export const container = createContainer('sidebar') +export const anonymous = createContainer()" + ), + r#"import { globalCss } from '@devup-ui/react' +globalCss`@layer layer-0-0,base-0-1,base-0-1.child-0-2,layer-0-3,utilities,utilities.x;` +export const reset = "layer-0-0" +export const base = "base-0-1" +export const child = "base-0-1.child-0-2" +export const orphan = "layer-0-3" +export const named = "utilities" +export const nested = "utilities.x" +export const again = "utilities" +export const container = "sidebar-0-4" +export const anonymous = "container-0-5""# ); } #[test] - fn test_execute_vanilla_extract_with_conditional() { - // Test conditional expressions - let code = r#"import { style } from '@devup-ui/react' -const isDark = true; -export const theme = style({ background: isDark ? "black" : "white" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("theme")); - let entry = &result.styles["theme"]; - assert!( - entry.json.contains("black"), - "Expected 'black' in JSON: {}", - entry.json - ); + #[serial] + fn test_themes_and_contracts() { + assert_eq!( + generate( + "const contract = createThemeContract({ color: { brand: null, text: null }, list: [1], flag: true }) +export const [light, lightVars] = createTheme({ '@layer': 'theme', color: { brand: 'blue' }, size: 4 }) +export const dark = createTheme(contract, { color: { brand: 'black', text: 'white' } }, 'dark') +export const partial = createTheme(contract, { color: { brand: 'x', missing: 'y' } }) +export const emptyTheme = createTheme(contract, {}) +export const globalVars = createGlobalTheme(':root', { space: '1px' }) +createGlobalTheme('.scope', contract, { '@layer': 'scoped', color: { text: 'grey' } }) +export const exact = createGlobalThemeContract({ color: 'brand-color', raw: '--raw' }) +export const mapped = createGlobalThemeContract({ color: { brand: null } }, (_value, path) => `x-${path.join('-')}`) +export const assigned = assignVars(contract, { color: { brand: 'red' } }) +export const unassigned = assignVars(undefined, { a: 'b' }) +export const box = style({ vars: assigned }) +export const notObject = createTheme('tokens')" + ), + r#"import { css, globalCss } from '@devup-ui/react' +export const box = css({"vars":{"--color-brand-0-0":"red"}}) +globalCss({ ".theme-0-2": {"@layer":"theme","--color-brand-0-3":"blue","--size-0-4":"4"} }) +globalCss({ ".dark-0-5": {"--color-brand-0-0":"black","--color-text-0-1":"white"} }) +globalCss({ ".theme-0-6": {"--color-brand-0-0":"x"} }) +globalCss({ ":root": {"--space-0-8":"1px"} }) +globalCss({ ".scope": {"@layer":"scoped","--color-text-0-1":"grey"} }) +export const light = "theme-0-2" +export const lightVars = { "color": { "brand": "var(--color-brand-0-3)" }, "size": "var(--size-0-4)" } +export const dark = "dark-0-5" +export const partial = "theme-0-6" +export const emptyTheme = "theme-0-7" +export const globalVars = { "space": "var(--space-0-8)" } +export const exact = { "color": "var(--brand-color)", "raw": "var(--raw)" } +export const mapped = { "color": { "brand": "var(--x-color-brand)" } } +export const assigned = { "var(--color-brand-0-0)": "red" } +export const unassigned = {} +export const notObject = ["theme-0-9", {}]"# + ); + } + + #[test] + #[serial] + fn test_nothing_collected() { + assert_eq!(generate("const x = 1"), ""); + reset_file_map(); + assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); + } + + #[test] + fn test_identifier() { + let mut collector = Collector { + file_num: 3, + ..Collector::default() + }; + assert_eq!(collector.identifier(Some("a b".into()), "x"), "a_b-3-0"); + assert_eq!(collector.identifier(Some("-y".into()), "x"), "_-y-3-1"); + assert_eq!(collector.identifier(Some(String::new()), "x"), "x-3-2"); + assert_eq!(collector.identifier(None, "_z"), "_z-3-3"); } #[test] - fn test_execute_vanilla_extract_with_spread() { - // Test spread operator - let code = r#"import { style } from '@devup-ui/react' -const baseStyle = { padding: 8, margin: 4 }; -export const extended = style({ ...baseStyle, background: "red" })"#; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - assert!(result.styles.contains_key("extended")); - let entry = &result.styles["extended"]; - assert!( - entry.json.contains('8'), - "Expected padding 8 in JSON: {}", - entry.json - ); - assert!( - entry.json.contains('4'), - "Expected margin 4 in JSON: {}", - entry.json + fn test_preprocess_typescript_binds_imports() { + assert_eq!( + preprocess_typescript( + "import { style as s, globalStyle } from '@devup-ui/react' +import * as ve from '@devup-ui/react' +import '@devup-ui/react' +import { other } from 'other' +interface Props { color: string } +export const a: number = s(globalStyle, ve, other) +export function f() {} +export default a", + PACKAGE + ), + "const { style: s, globalStyle } = __vanilla_extract__; +const ve = __vanilla_extract__; +import \"@devup-ui/react\"; +import { other } from \"other\"; +const a = s(globalStyle, ve, other); +function f() {} +export default a;" ); - assert!( - entry.json.contains("red"), - "Expected 'red' in JSON: {}", - entry.json + assert_eq!( + preprocess_typescript( + "import { style } from '@devup-ui/react'\nconst a = 1", + PACKAGE + ), + "const a = 1;\n" ); } #[test] - fn test_execute_vanilla_extract_create_theme_contract() { - // Test createThemeContract + createTheme - let code = r"import { createThemeContract, createTheme } from '@devup-ui/react' -const vars = createThemeContract({ - color: { - brand: null, - text: null - } -}) -export const lightTheme = createTheme(vars, { - color: { - brand: 'blue', - text: 'black' - } -})"; - let result = execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts").unwrap(); - - // Check that themes were collected - assert!( - !result.themes.is_empty(), - "Expected themes to be collected, got empty. Themes: {:?}", - result.themes - ); - assert!( - result.themes.contains_key("lightTheme"), - "Expected lightTheme in themes: {:?}", - result.themes.keys().collect::>() - ); - - // Check CSS vars - let theme_entry = &result.themes["lightTheme"]; - assert!( - !theme_entry.css_vars.is_empty(), - "Expected CSS vars in theme: {theme_entry:?}" - ); - - // Check that CSS vars are correct - let css_vars: Vec<_> = theme_entry.css_vars.iter().collect(); - assert!( - css_vars - .iter() - .any(|(name, val)| name == "--color-brand" && val == "blue"), - "Expected --color-brand: blue in {css_vars:?}" + fn test_style_to_json_adds_vanilla_extract_units() { + let mut context = Context::default(); + let rule = context + .eval(Source::from_bytes( + "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 }, vars: { 'var(--a)': 1, '--b': 2 } })", + )) + .unwrap(); + assert_eq!( + style_to_json(&rule, &mut context), + r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"},"vars":{"--a":1,"--b":2}}"# ); + assert_eq!(style_to_json(&JsValue::undefined(), &mut context), "{}"); } #[test] - fn test_parse_font_face_json_edge_cases() { - // Test invalid JSON - let result = super::parse_font_face_json("not valid json"); - assert!(result.is_empty()); - - // Test non-object JSON - let result = super::parse_font_face_json("\"just a string\""); - assert!(result.is_empty()); - - // Test array JSON - let result = super::parse_font_face_json("[1, 2, 3]"); - assert!(result.is_empty()); - - // Test with boolean value - let result = super::parse_font_face_json(r#"{"fontDisplay": true}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontDisplay".to_string(), "true".to_string())); - - // Test with number value - let result = super::parse_font_face_json(r#"{"fontWeight": 400}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontWeight".to_string(), "400".to_string())); - - // Test with null value (other case) - let result = super::parse_font_face_json(r#"{"fontStyle": null}"#); - assert_eq!(result.len(), 1); - assert_eq!(result[0], ("fontStyle".to_string(), "null".to_string())); + #[serial] + fn test_referenced_keyframes() { + reset_file_map(); + let collected = execute_vanilla_extract( + "import { style, keyframes } from '@devup-ui/react' +const fade = keyframes({ from: { opacity: 0 } }) +const unused = keyframes({ to: { opacity: 1 } }) +export const box = style({ animationName: fade })", + PACKAGE, + "test.css.ts", + ) + .unwrap(); + let names = referenced_keyframes(&collected); + assert_eq!(names, FxHashSet::from_iter(["fade".to_string()])); + assert_eq!( + keyframes_to_code(&collected, PACKAGE, &names), + r#"import { keyframes } from '@devup-ui/react' +const fade = keyframes({"from":{"opacity":0}})"# + ); } #[test] - fn test_collected_styles_to_code_with_classes_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style with bases - collected.styles.insert( - "composed".to_string(), - StyleEntry { - json: r#"{"color":"red"}"#.to_string(), - exported: true, - bases: smallvec!["base".to_string()], - }, - ); - - let class_map: rustc_hash::FxHashMap = [ - ("base".to_string(), "a".to_string()), - ("composed".to_string(), "b".to_string()), - ] - .into_iter() - .collect(); - - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("import { css } from '@devup-ui/react'")); - // The composed style should have both base and own styles merged - assert!(code.contains("padding")); - assert!(code.contains("color")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_vars() { - let mut collected = CollectedStyles::default(); - // Theme with vars_name and vars_object_json - collected.themes.insert( - "themeClass".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some( - r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - ), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const [themeClass, vars] = [\"f0_theme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_theme_no_vars() { - let mut collected = CollectedStyles::default(); - // Theme without vars (two-arg createTheme) - collected.themes.insert( - "darkTheme".to_string(), - super::ThemeEntry { - class_name: "f1_darkTheme".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "white".to_string())], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const darkTheme = \"f1_darkTheme\"")); - } - - #[test] - fn test_collected_styles_to_code_with_keyframes() { - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "fadeIn".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"},"to":{"opacity":"1"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { keyframes } from '@devup-ui/react'")); - assert!(code.contains("export const fadeIn = keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_global_styles() { - let mut collected = CollectedStyles::default(); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("import { globalCss } from '@devup-ui/react'")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_collected_styles_to_code_composition() { - let mut collected = CollectedStyles::default(); - // Add base style - collected.styles.insert( - "baseStyle".to_string(), - StyleEntry { - json: r#"{"padding":"16px","margin":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - // Add composed style - collected.styles.insert( - "buttonStyle".to_string(), - StyleEntry { - json: r#"{"background":"blue"}"#.to_string(), - exported: true, - bases: smallvec!["baseStyle".to_string()], - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Both base and own styles should be present in the composed style - assert!(code.contains("padding")); - assert!(code.contains("background")); - } - - #[test] - fn test_transform_contract_to_vars_array() { - // Test transform_contract_to_vars with array (covers line 170) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_array() { - // Test transform_theme_to_vars with array (covers line 250) - let mut context = boa_engine::Context::default(); - let array = boa_engine::object::builtins::JsArray::new(&mut context) - .expect("creating an empty array should succeed"); - let _ = array.push(boa_engine::JsValue::from(1), &mut context); - let value = boa_engine::JsValue::from(array); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Arrays should be returned as-is - assert!(result.as_object().is_some()); - } - - #[test] - fn test_transform_contract_to_vars_with_symbol_property() { - // Test transform_contract_to_vars with Symbol property (covers line 180) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("normalProp"), - JsValue::null(), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("testSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("symbolValue"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - - // Should succeed and only process normalProp (Symbol should be skipped) - assert!(result.as_object().is_some()); - let result_obj = result.as_object().unwrap(); - // normalProp should be transformed to var(--normalProp) - let normal_prop = result_obj - .get(js_string!("normalProp"), &mut context) - .unwrap(); - assert!(normal_prop.as_string().is_some()); - } - - #[test] - fn test_transform_theme_to_vars_with_symbol_property() { - // Test transform_theme_to_vars with Symbol property (covers line 256) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create an object with both string and symbol properties - let obj = ObjectInitializer::new(&mut context).build(); - let _ = obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("hiddenSymbol"))).unwrap(); - let _ = obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(js_string!("hidden"))) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - - // Should succeed - Symbol property should be skipped - assert!(result.as_object().is_some()); - // Only the string property should generate a CSS var - assert_eq!(css_vars.len(), 1); - assert!(css_vars[0].0.contains("color")); - } - - #[test] - fn test_extract_theme_vars_with_symbol_property() { - // Test extract_theme_vars with Symbol property (covers line 211) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create contract object with symbol - let contract_obj = ObjectInitializer::new(&mut context).build(); - let _ = contract_obj.set( - js_string!("primary"), - JsValue::from(js_string!("var(--primary)")), - false, - &mut context, - ); - let symbol = JsSymbol::new(Some(js_string!("contractSymbol"))).unwrap(); - let _ = contract_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::null()) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - // Create values object - let values_obj = ObjectInitializer::new(&mut context).build(); - let _ = values_obj.set( - js_string!("primary"), - JsValue::from(js_string!("blue")), - false, - &mut context, - ); - - let contract = JsValue::from(contract_obj); - let values = JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - - // Should extract only the string property, symbol should be skipped - assert_eq!(css_vars.len(), 1); - assert_eq!(css_vars[0], ("--primary".to_string(), "blue".to_string())); - } - - #[test] - fn test_parse_style_variants_with_symbol_property() { - // Test parse_style_variants with Symbol property (covers line 1425) - use boa_engine::{ - Context, JsSymbol, JsValue, js_string, object::ObjectInitializer, property::PropertyKey, - }; - - let mut context = Context::default(); - - // Create variants object with both string and symbol properties - let variants_obj = ObjectInitializer::new(&mut context).build(); - - // Add a normal variant - let style_obj = ObjectInitializer::new(&mut context).build(); - let _ = style_obj.set( - js_string!("color"), - JsValue::from(js_string!("red")), - false, - &mut context, - ); - let _ = variants_obj.set( - js_string!("primary"), - JsValue::from(style_obj), - false, - &mut context, - ); - - // Add a Symbol property - let symbol = JsSymbol::new(Some(js_string!("variantSymbol"))).unwrap(); - let hidden_style = ObjectInitializer::new(&mut context).build(); - let _ = hidden_style.set( - js_string!("display"), - JsValue::from(js_string!("none")), - false, - &mut context, - ); - let _ = variants_obj.define_property_or_throw( - PropertyKey::Symbol(symbol), - boa_engine::property::PropertyDescriptor::builder() - .value(JsValue::from(hidden_style)) - .writable(true) - .enumerable(true) - .configurable(true) - .build(), - &mut context, - ); - - let value = JsValue::from(variants_obj); - let result = super::parse_style_variants(&value, &mut context); - - // Should only contain the string-keyed variant, symbol should be skipped - assert_eq!(result.len(), 1); - assert!(result.contains_key("primary")); - } - - #[test] - fn test_extract_theme_vars_non_matching() { - // Test extract_theme_vars with non-matching structure - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("not-a-var")); - let values = boa_engine::JsValue::from(boa_engine::js_string!("some-value")); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // No CSS vars should be extracted because contract isn't a var() string - assert!(css_vars.is_empty()); - } - - #[test] - fn test_js_value_to_json_edge_cases() { - // Test js_value_to_json with various types (covers lines 314-328) - let mut context = boa_engine::Context::default(); - - // Test with boolean - let bool_val = boa_engine::JsValue::from(true); - let result = super::js_value_to_json(&bool_val, &mut context); - assert_eq!(result, "true"); - - // Test with null - let null_val = boa_engine::JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); - - // Test with undefined - let undef_val = boa_engine::JsValue::undefined(); - let result = super::js_value_to_json(&undef_val, &mut context); - assert_eq!(result, "undefined"); - - // Test with number - let num_val = boa_engine::JsValue::from(42); - let result = super::js_value_to_json(&num_val, &mut context); - assert_eq!(result, "42"); - } - - #[test] - fn test_collected_styles_to_code_with_vars() { - // Test with createVar exports (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--var-0".to_string(), true)); - collected - .vars - .insert("sizeVar".to_string(), ("--var-1".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--var-0\"")); - assert!(code.contains("const sizeVar = \"--var-1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_containers() { - // Test with createContainer exports (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected - .containers - .insert("sidebar".to_string(), ("__container_0__".to_string(), true)); - collected.containers.insert( - "internalCont".to_string(), - ("__container_1__".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_0__\"")); - assert!(code.contains("const internalCont = \"__container_1__\"")); - } - - #[test] - fn test_collected_styles_to_code_with_layers() { - // Test with layer exports (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("resetLayer".to_string(), ("reset".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const resetLayer = \"reset\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_collected_styles_to_code_with_font_faces() { - // Test with fontFace exports (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"local(Arial)"}"#.to_string(), - "__devup_font_0_0".to_string(), - true, - ), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r#"{"src":"local(Times)"}"#.to_string(), - "__devup_font_0_1".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const myFont = \"__devup_font_0_0\"")); - assert!(code.contains("const internalFont = \"__devup_font_0_1\"")); - } - - #[test] - fn test_collected_styles_to_code_with_global_themes() { - // Test with createGlobalTheme exports (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--color-primary".to_string(), "blue".to_string())], - vars_object_json: r#"{"color":{"primary":"var(--color-primary)"}}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const rootVars =")); - assert!(code.contains("globalCss({")); - } - - #[test] - fn test_collected_styles_to_code_with_constant_exports() { - // Test with constant exports (covers line 1430) - let mut collected = CollectedStyles::default(); - collected - .constant_exports - .insert("SPACING".to_string(), "8".to_string()); - collected - .constant_exports - .insert("COLORS".to_string(), "{ primary: 'blue' }".to_string()); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const SPACING = 8")); - assert!(code.contains("export const COLORS = { primary: 'blue' }")); - } - - #[test] - fn test_collected_styles_to_code_empty_font_face_props() { - // Test fontFace with empty properties (covers line 1324 branch) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "emptyFont".to_string(), - ("{}".to_string(), "__devup_font_empty".to_string(), true), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFamily: \"__devup_font_empty\"")); - } - - #[test] - fn test_collected_styles_to_code_global_theme_empty_vars() { - // Test createGlobalTheme with empty css_vars (covers line 1332 branch) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "emptyTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![], - vars_object_json: "{}".to_string(), - exported: true, - }, - ); - // Add a style to trigger import generation - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should not generate globalCss for empty vars - assert!(code.contains("export const emptyTheme = {}")); - } - - #[test] - fn test_style_variants_without_base() { - // Test styleVariants without base composition (covers line 1179) - let mut collected = CollectedStyles::default(); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "small".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "large".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sizes = {")); - assert!(code.contains("small: css")); - assert!(code.contains("large: css")); - } - - #[test] - fn test_style_variants_with_base_empty_inner() { - // Test styleVariants with base but empty inner styles (covers lines 1169, 1174 branches) - let mut collected = CollectedStyles::default(); - // Base with empty inner - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "variant".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: "{}".to_string(), - }, - ); - collected - .style_variants - .insert("empty".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("variant: css")); - } - - #[test] - fn test_collected_styles_clone() { - // Test Clone implementation for CollectedStyles (covers line 1437-1438) - let mut original = CollectedStyles::default(); - original.styles.insert( - "test".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .global_styles - .push(("body".to_string(), "{}".to_string())); - original.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: "{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - original - .vars - .insert("v".to_string(), ("--v".to_string(), true)); - original - .font_faces - .insert("f".to_string(), ("{}".to_string(), "ff".to_string(), true)); - original - .containers - .insert("c".to_string(), ("cn".to_string(), true)); - original - .layers - .insert("l".to_string(), ("ln".to_string(), true)); - original - .constant_exports - .insert("C".to_string(), "1".to_string()); - - let cloned = original.clone(); - assert_eq!(cloned.styles.len(), original.styles.len()); - assert_eq!(cloned.global_styles.len(), original.global_styles.len()); - } - - #[test] - fn test_parse_style_variants_index_key() { - // Test parse_style_variants with numeric index key (covers line 1454) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property with numeric key by setting index - let _ = obj.set( - 0, - boa_engine::JsValue::from( - boa_engine::object::ObjectInitializer::new(&mut context).build(), - ), - false, - &mut context, - ); - - let value = boa_engine::JsValue::from(obj); - let result = super::parse_style_variants(&value, &mut context); - // Should have one variant with key "0" - assert!(result.contains_key("0") || result.is_empty()); // Implementation may skip numeric - } - - #[test] - fn test_extract_var_names_non_style_api() { - // Test extract_var_names with non-style-api expressions - let code = r"import { style } from '@devup-ui/react' -export const CONSTANT = 42; -export const OBJECT = { key: 'value' }; -export const computed = 1 + 2; -"; - let vars = super::extract_var_names(code, "@devup-ui/react"); - // Should have constants - assert!(vars.iter().any(|(name, _)| name == "CONSTANT")); - assert!(vars.iter().any(|(name, _)| name == "OBJECT")); - } - - #[test] - fn test_extract_var_names_skips_declaration_without_initializer() { - let code = r"import { style } from '@devup-ui/react' -let pending; -export const ready = style({ color: 'red' });"; - - let vars = super::extract_var_names(code, "@devup-ui/react"); - - assert!(!vars.iter().any(|(name, _)| name == "pending")); - assert!(vars.iter().any(|(name, _)| name == "ready")); - } - - #[test] - fn test_preprocess_typescript_single_quotes() { - // Test preprocess with single quotes in import - let code = r"import { style } from '@devup-ui/react' -export const box = style({ padding: 8 })"; - let result = super::preprocess_typescript(code, "@devup-ui/react"); - assert!(result.contains("__vanilla_extract__")); - assert!(!result.contains("export const")); - } - - // Note: @vanilla-extract/css import transformation is now handled by import_alias_visit.rs - // before the code reaches vanilla_extract.rs, so preprocess_typescript only needs to - // handle the target package imports - - #[test] - fn test_referenced_keyframes_without_references() { - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px","animation":"__style_9__ 1s"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - assert!(super::referenced_keyframes(&collected).is_empty()); - } - - #[test] - fn test_keyframes_to_code_only_referenced() { - let mut collected = CollectedStyles::default(); - for name in ["fade", "spin"] { - collected.keyframes.insert( - name.to_string(), - StyleEntry { - json: r#"{"from":{"opacity":0}}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - } - let names = std::iter::once("fade".to_string()).collect(); - assert_eq!( - super::keyframes_to_code(&collected, "@devup-ui/react", &names), - "import { keyframes } from '@devup-ui/react'\nconst fade = keyframes({\"from\":{\"opacity\":0}})" - ); - } - - #[test] - fn test_collected_styles_to_code_resolves_references() { - let mut collected = CollectedStyles::default(); - let entry = |json: &str| StyleEntry { - json: json.to_string(), - exported: true, - bases: SmallVec::new(), - }; - collected - .styles - .insert("parent".to_string(), entry(r#"{"background":"white"}"#)); - collected.styles.insert( - "child".to_string(), - entry(r#"{"animation":"__style_2__ 1s","selectors":{"__style_0__:hover &":{"color":"blue"}}}"#), - ); - collected - .keyframes - .insert("fade".to_string(), entry(r#"{"from":{"opacity":0}}"#)); - collected.global_styles.push(( - "__style_0__ > a".to_string(), - r#"{"color":"__style_1__"}"#.to_string(), - )); - collected.references.insert( - "__style_0__".to_string(), - Reference::Style { - name: "parent".to_string(), - class_name: "f0_parent".to_string(), - }, - ); - collected.references.insert( - "__style_1__".to_string(), - Reference::Class("f0_theme".to_string()), - ); - collected.references.insert( - "__style_2__".to_string(), - Reference::Keyframes("fade".to_string()), - ); - collected.references.insert( - "__font_0__".to_string(), - Reference::Font("__devup_font_0_0".to_string()), - ); - - let keyframes_names = std::iter::once(("fade".to_string(), "k".to_string())).collect(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &keyframes_names, - ); - assert!(code.contains(r#"const child = css({"animation":"k 1s","selectors":{".f0_parent:hover &":{"color":"blue"}}})"#), "{code}"); - assert!( - code.contains(r#"const parent = css({"background":"white"}) + " f0_parent""#), - "{code}" - ); - assert!( - code.contains(r#"globalCss({ ".f0_parent > a": {"color":"f0_theme"} })"#), - "{code}" - ); - // An unresolved keyframes reference is left as is. - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("__style_2__ 1s"), "{code}"); - } - - #[test] - fn test_transform_contract_to_vars_with_index_key() { - // Test transform_contract_to_vars with index/numeric keys (covers line 181) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let inner = boa_engine::JsValue::null(); - let _ = obj.set(0u32, inner, false, &mut context); - let value = boa_engine::JsValue::from(obj); - let result = super::transform_contract_to_vars(&value, &mut context, &mut Vec::new()); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_with_index_key() { - // Test extract_theme_vars with index/numeric keys (covers line 212) - let mut context = boa_engine::Context::default(); - let contract_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - let values_obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = contract_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("var(--test)")), - false, - &mut context, - ); - let _ = values_obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("blue")), - false, - &mut context, - ); - let contract = boa_engine::JsValue::from(contract_obj); - let values = boa_engine::JsValue::from(values_obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should process index keys - assert!(css_vars.is_empty() || !css_vars.is_empty()); - } - - #[test] - fn test_transform_theme_to_vars_with_index_key() { - // Test transform_theme_to_vars with index/numeric keys (covers line 263) - let mut context = boa_engine::Context::default(); - let obj = boa_engine::object::ObjectInitializer::new(&mut context).build(); - // Set property using numeric index - let _ = obj.set( - 0u32, - boa_engine::JsValue::from(boa_engine::js_string!("value")), - false, - &mut context, - ); - let value = boa_engine::JsValue::from(obj); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should handle index keys - assert!(result.as_object().is_some()); - } - - #[test] - fn test_extract_theme_vars_value_conversion() { - // Test extract_theme_vars with value that needs to_string conversion (covers lines 234, 236) - let mut context = boa_engine::Context::default(); - let contract = boa_engine::JsValue::from(boa_engine::js_string!("var(--num)")); - // Use a number instead of string - will need to_string conversion - let values = boa_engine::JsValue::from(42); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - super::extract_theme_vars( - &contract, - &values, - &mut context, - &mut css_vars, - &mut Vec::new(), - ); - // Should extract the value as string - assert!(!css_vars.is_empty()); - assert_eq!(css_vars[0].0, "--num"); - assert_eq!(css_vars[0].1, "42"); - } - - #[test] - fn test_transform_theme_to_vars_value_conversion() { - // Test transform_theme_to_vars with value that needs to_string conversion (covers lines 287, 289) - let mut context = boa_engine::Context::default(); - // Use a number instead of string - will need to_string conversion - let value = boa_engine::JsValue::from(123); - let mut css_vars: SmallVec<[(String, String); 8]> = SmallVec::new(); - let mut counter = 0usize; - let _result = super::transform_theme_to_vars( - &value, - &mut context, - "__test__", - &mut css_vars, - &mut counter, - &mut Vec::new(), - ); - // Should have converted the number to string - assert!(!css_vars.is_empty()); - assert!(css_vars[0].1.contains("123")); - } - - #[test] - fn test_js_value_to_json_string_fallback() { - // Test js_value_to_json string fallback path (covers lines 316, 318-319) - let mut context = boa_engine::Context::default(); - - // Create a string value that can't use JSON.stringify - let string_val = boa_engine::JsValue::from(boa_engine::js_string!("test string")); - let result = super::js_value_to_json(&string_val, &mut context); - // Should contain the string (either via stringify or fallback) - assert!(result.contains("test string") || result.contains("\"test string\"")); - } - - #[test] - fn test_js_value_to_json_number_fallback() { - // Test js_value_to_json number fallback path (covers lines 322-323, 325) - let mut context = boa_engine::Context::default(); - - // Test with NaN - let nan_val = boa_engine::JsValue::from(f64::NAN); - let result = super::js_value_to_json(&nan_val, &mut context); - // NaN should return "NaN" in fallback - assert!(result == "null" || result == "NaN" || result.contains("NaN")); - - // Test with Infinity - let inf_val = boa_engine::JsValue::from(f64::INFINITY); - let result = super::js_value_to_json(&inf_val, &mut context); - // Infinity should return some representation - assert!(!result.is_empty()); - } - - #[test] - fn test_js_value_to_json_object_fallback() { - // Test js_value_to_json object fallback path (covers line 328) - let mut context = boa_engine::Context::default(); - - // Create a symbol which can't be JSON stringified - let symbol = boa_engine::JsSymbol::new(Some(boa_engine::js_string!("test"))); - if let Some(sym) = symbol { - let symbol_val = boa_engine::JsValue::from(sym); - let result = super::js_value_to_json(&symbol_val, &mut context); - // Symbols should fall back to "{}" or some default - assert!(!result.is_empty()); - } - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_vars() { - // Test collected_styles_to_code_with_classes with themes that have vars (covers lines 1103-1106) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "theme".to_string(), - super::ThemeEntry { - class_name: "f0_theme".to_string(), - css_vars: smallvec![("--color".to_string(), "blue".to_string())], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: Some(r#"{"color":"var(--color)"}"#.to_string()), - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("[theme, vars]")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars() { - // Test collected_styles_to_code_with_classes with theme without vars_object_json (covers lines 1108, 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("vars".to_string()), - vars_object_json: None, // No vars object - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_full() { - // Test append_non_style_code with all types populated (covers lines 1125, 1132-1135, 1142-1144, 1152-1153) - let mut collected = CollectedStyles::default(); - - // Add global styles - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - - // Add font faces with properties - collected.font_faces.insert( - "customFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"400"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - // Add global themes with CSS vars - collected.global_themes.insert( - "rootTheme".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--bg".to_string(), "white".to_string()), - ("--fg".to_string(), "black".to_string()), - ], - vars_object_json: r#"{"bg":"var(--bg)","fg":"var(--fg)"}"#.to_string(), - exported: true, - }, - ); - - // Add keyframes - collected.keyframes.insert( - "bounce".to_string(), - StyleEntry { - json: r#"{"0%":{"transform":"scale(1)"},"50%":{"transform":"scale(1.1)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should include all types - assert!(code.contains("globalCss")); - assert!(code.contains("fontFamily")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_style_variants_with_base_composition() { - // Test styleVariants with base style composition in code generation (covers lines 1161-1170, 1173-1175, 1177) - let mut collected = CollectedStyles::default(); - - // Add base style - collected.styles.insert( - "baseBtn".to_string(), - StyleEntry { - json: r#"{"borderRadius":"4px","padding":"8px"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - // Add styleVariants with base - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "primary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"blue","color":"white"}"#.to_string(), - }, - ); - variants.insert( - "secondary".to_string(), - super::StyleVariant { - base: Some("baseBtn".to_string()), - styles_json: r#"{"backgroundColor":"gray"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("buttonVariants".to_string(), (variants, true)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should have merged styles from base - assert!(code.contains("buttonVariants")); - assert!(code.contains("borderRadius") || code.contains("primary")); - } - - #[test] - fn test_collected_styles_with_classes_style_variants() { - // Test collected_styles_to_code_with_classes with styleVariants (covers lines 1161-1184 in with_classes version) - let mut collected = CollectedStyles::default(); - - collected.styles.insert( - "base".to_string(), - StyleEntry { - json: r#"{"display":"flex"}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let mut variants = rustc_hash::FxHashMap::default(); - variants.insert( - "sm".to_string(), - super::StyleVariant { - base: Some("base".to_string()), - styles_json: r#"{"fontSize":"12px"}"#.to_string(), - }, - ); - variants.insert( - "lg".to_string(), - super::StyleVariant { - base: None, - styles_json: r#"{"fontSize":"20px"}"#.to_string(), - }, - ); - collected - .style_variants - .insert("sizes".to_string(), (variants, true)); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("sizes")); - } - - #[test] - fn test_collected_styles_constant_exports_in_with_classes() { - // Test constant exports in collected_styles_to_code_with_classes (covers line 1229) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected - .constant_exports - .insert("CONFIG".to_string(), "{ debug: true }".to_string()); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("export const CONFIG")); - } - - #[test] - fn test_theme_entry_vars_name_without_json() { - // Test theme with vars_name but without vars_object_json (covers line 1301) - let mut collected = CollectedStyles::default(); - collected.themes.insert( - "partialTheme".to_string(), - super::ThemeEntry { - class_name: "f0_partial".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: Some("themeVars".to_string()), - vars_object_json: None, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - // Should output just the class name assignment - assert!(code.contains("const partialTheme = \"f0_partial\"")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_all_imports() { - // Test import generation for css, globalCss, keyframes (covers lines 1049, 1052) - let mut collected = CollectedStyles::default(); - - // Add style to trigger css import - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r#"{"padding":"8px"}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - // Add global style to trigger globalCss import - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - // Add keyframes to trigger keyframes import - collected.keyframes.insert( - "fade".to_string(), - StyleEntry { - json: r#"{"from":{"opacity":"0"}}"#.to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - - let class_map: rustc_hash::FxHashMap = - std::iter::once(("box".to_string(), "a".to_string())).collect(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - - assert!(code.contains("css")); - assert!(code.contains("globalCss")); - assert!(code.contains("keyframes")); - } - - #[test] - fn test_collected_styles_to_code_with_classes_theme_no_vars_name() { - // Test theme without vars_name (covers line 1111) - let mut collected = CollectedStyles::default(); - collected.styles.insert( - "box".to_string(), - StyleEntry { - json: r"{}".to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.themes.insert( - "simpleTheme".to_string(), - super::ThemeEntry { - class_name: "f0_simple".to_string(), - css_vars: smallvec![], - exported: true, - vars_name: None, - vars_object_json: None, - }, - ); - - let class_map = rustc_hash::FxHashMap::default(); - let code = super::collected_styles_to_code_with_keyframes( - &collected, - "@devup-ui/react", - &class_map, - ); - assert!(code.contains("const simpleTheme = \"f0_simple\"")); - } - - #[test] - fn test_append_non_style_code_global_styles() { - // Test globalCss code generation (covers line 1125) - let mut collected = CollectedStyles::default(); - collected.global_styles.push(( - "html".to_string(), - r#"{"boxSizing":"border-box"}"#.to_string(), - )); - collected - .global_styles - .push(("body".to_string(), r#"{"margin":"0"}"#.to_string())); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \"html\":")); - assert!(code.contains("globalCss({ \"body\":")); - } - - #[test] - fn test_append_non_style_code_font_faces_with_props() { - // Test fontFace code generation with properties (covers lines 1132-1135) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "myFont".to_string(), - ( - r#"{"src":"url(font.woff2)","fontWeight":"bold"}"#.to_string(), - "__font_0".to_string(), - true, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("fontFaces")); - assert!(code.contains("fontFamily")); - } - - #[test] - fn test_append_non_style_code_global_themes_with_vars() { - // Test createGlobalTheme code generation with CSS vars (covers lines 1142-1144) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "rootVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![ - ("--primary".to_string(), "blue".to_string()), - ("--secondary".to_string(), "green".to_string()), - ], - vars_object_json: r#"{"primary":"var(--primary)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("globalCss({ \":root\":")); - assert!(code.contains("--primary")); - } - - #[test] - fn test_append_non_style_code_keyframes() { - // Test keyframes code generation (covers lines 1152-1153) - let mut collected = CollectedStyles::default(); - collected.keyframes.insert( - "spin".to_string(), - StyleEntry { - json: r#"{"from":{"transform":"rotate(0)"},"to":{"transform":"rotate(360deg)"}}"# - .to_string(), - exported: true, - bases: SmallVec::new(), - }, - ); - collected.keyframes.insert( - "internalFade".to_string(), - StyleEntry { - json: r#"{"0%":{"opacity":"0"}}"#.to_string(), - exported: false, - bases: SmallVec::new(), - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const spin = keyframes")); - assert!(code.contains("const internalFade = keyframes")); - } - - #[test] - fn test_append_non_style_code_vars() { - // Test createVar code generation (covers lines 1191-1192) - let mut collected = CollectedStyles::default(); - collected - .vars - .insert("colorVar".to_string(), ("--color-0".to_string(), true)); - collected.vars.insert( - "internalVar".to_string(), - ("--internal-0".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const colorVar = \"--color-0\"")); - assert!(code.contains("const internalVar = \"--internal-0\"")); - } - - #[test] - fn test_append_non_style_code_font_face_declarations() { - // Test fontFace declaration code generation (covers lines 1199-1200) - let mut collected = CollectedStyles::default(); - collected.font_faces.insert( - "customFont".to_string(), - (r"{}".to_string(), "__devup_font_custom".to_string(), true), - ); - collected.font_faces.insert( - "internalFont".to_string(), - ( - r"{}".to_string(), - "__devup_font_internal".to_string(), - false, - ), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const customFont = \"__devup_font_custom\"")); - assert!(code.contains("const internalFont = \"__devup_font_internal\"")); - } - - #[test] - fn test_append_non_style_code_containers() { - // Test createContainer code generation (covers lines 1207-1208) - let mut collected = CollectedStyles::default(); - collected.containers.insert( - "sidebar".to_string(), - ("__container_sidebar".to_string(), true), - ); - collected.containers.insert( - "internalCont".to_string(), - ("__container_internal".to_string(), false), - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const sidebar = \"__container_sidebar\"")); - assert!(code.contains("const internalCont = \"__container_internal\"")); - } - - #[test] - fn test_append_non_style_code_layers() { - // Test layer code generation (covers lines 1215-1216) - let mut collected = CollectedStyles::default(); - collected - .layers - .insert("baseLayer".to_string(), ("base".to_string(), true)); - collected - .layers - .insert("internalLayer".to_string(), ("internal".to_string(), false)); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const baseLayer = \"base\"")); - assert!(code.contains("const internalLayer = \"internal\"")); - } - - #[test] - fn test_append_non_style_code_global_theme_vars_object() { - // Test createGlobalTheme vars object declaration (covers lines 1221-1222) - let mut collected = CollectedStyles::default(); - collected.global_themes.insert( - "themeVars".to_string(), - super::GlobalThemeEntry { - selector: ":root".to_string(), - css_vars: smallvec![("--bg".to_string(), "white".to_string())], - vars_object_json: r#"{"bg":"var(--bg)"}"#.to_string(), - exported: true, - }, - ); - - let code = super::collected_styles_to_code(&collected, "@devup-ui/react"); - assert!(code.contains("export const themeVars = {\"bg\":\"var(--bg)\"}")); - } - - #[test] - fn test_style_to_json_adds_vanilla_extract_units() { - let mut context = Context::default(); - let rule = context - .eval(Source::from_bytes( - "({ fontSize: 16, top: 0, lineHeight: 1.5, '--gap': 4, 'var(--x)': 2, fallback: [1, 2], ':hover': { width: 3 } })", - )) - .unwrap(); - assert_eq!( - super::style_to_json(&rule, &mut context), - r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"}}"# - ); - assert_eq!( - super::style_to_json(&JsValue::undefined(), &mut context), - "undefined" - ); - } - - #[test] - fn test_style_with_non_object_argument() { - // Test style() with primitive value (covers line 749) - // This is tested through execute_vanilla_extract with edge case input - let code = r" -import { style } from '@devup-ui/react' -// This should still work - style with null or undefined would be an edge case -export const empty = style({}) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - } - - #[test] - fn test_style_with_object_no_length() { - // Test style() with regular object (covers line 745) - let code = r" -import { style } from '@devup-ui/react' -export const box = style({ padding: 8, margin: 4 }) -"; - let result = super::execute_vanilla_extract(code, "@devup-ui/react", "test.css.ts"); - assert!(result.is_ok()); - let collected = result.unwrap(); - assert!(!collected.styles.is_empty()); - } - - #[test] - fn test_js_value_to_json_string_fallback_path() { - // Test js_value_to_json string fallback when JSON.stringify is broken (covers line 303) - use boa_engine::{Context, JsValue, Source, js_string}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let string_val = JsValue::from(js_string!("test")); - let result = super::js_value_to_json(&string_val, &mut context); - assert_eq!(result, "\"test\""); - } - - #[test] - fn test_js_value_to_json_null_fallback_path() { - // Test js_value_to_json null fallback when JSON.stringify is broken (covers line 304) - use boa_engine::{Context, JsValue, Source}; - - let mut context = Context::default(); - - // Break JSON.stringify by setting it to undefined - context - .eval(Source::from_bytes(b"JSON.stringify = undefined")) - .unwrap(); - - let null_val = JsValue::null(); - let result = super::js_value_to_json(&null_val, &mut context); - assert_eq!(result, "null"); + fn test_inner_json() { + assert_eq!(inner_json(r#" {"a":{"b":1}} "#), r#""a":{"b":1}"#); + assert_eq!(inner_json("[1]"), ""); } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f6cfc066f..4ee13f5d4 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -46,9 +46,9 @@ use oxc_ast_visit::walk_mut::{ use strum::IntoEnumIterator; use crate::utils::{ - ParsedStyleOrder, expression_to_style_order, get_str_by_property_key, + ParsedStyleOrder, StyleArguments, expression_to_style_order, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, jsx_expression_to_style_order, - unwrap_syntax_only, + style_arguments, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; @@ -675,6 +675,24 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_expression(&mut self, it: &mut Expression<'a>) { + // Emotion's `styled(tag, options)` is called for a component, so its second + // argument holds options rather than the rules of Devup UI's two-argument form + let factory = match it { + Expression::CallExpression(call) => Some(&mut call.callee), + Expression::TaggedTemplateExpression(tag) => Some(&mut tag.tag), + _ => None, + }; + if let Some(styled_name) = &self.styled_import + && let Some(factory) = factory + && let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) + && call.arguments.len() == 2 + && call.arguments[1].as_expression().is_some_and(|options| { + matches!(unwrap_syntax_only(options), Expression::ObjectExpression(_)) + }) + && matches!(&call.callee, Expression::Identifier(ident) if ident.name == styled_name.as_str()) + { + call.arguments.truncate(1); + } walk_expression(self, it); // Handle styled function calls @@ -1082,6 +1100,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }; if let Some(util_type) = util_type { + let composed_classes = if matches!(util_type.as_ref(), UtilType::Css) + && let Some(StyleArguments { classes, rules }) = + style_arguments(&self.ast, &call.arguments) + { + call.arguments = + oxc_allocator::Vec::from_array_in([Argument::from(rules)], &self.ast); + classes + } else { + vec![] + }; if call.arguments.len() == 1 { let r = util_type.as_ref(); *it = if matches!(r, UtilType::Css) { @@ -1229,6 +1257,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { global => self.global_css_result(global.is_component()), }; } + if !composed_classes.is_empty() { + let own = std::mem::replace( + it, + Expression::new_string_literal(SPAN, "", None, &self.ast), + ); + *it = merge_expression_for_class_name( + &self.ast, + composed_classes.into_iter().chain([own]), + ) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); + } } } else if let Expression::TaggedTemplateExpression(tag) = it && let Expression::Identifier(ident) = &tag.tag From 7aaa19fe8ba03a773a25da41201505060ec17029 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:22:50 +0900 Subject: [PATCH 006/109] fix(sheet): write layered rules like the others and let typography yield Layered properties of any kind are written with the regular writer, one layer at a time, so a layer keeps breakpoints, selectors and at-rules. A typography atom skips the declarations its value lists as written beside it from the breakpoint they start at. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/sheet/src/lib.rs | 163 ++++++++++++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 46 deletions(-) diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index a2b824991..eb71220eb 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -223,7 +223,7 @@ pub struct StyleSheetCss { type PropertyMap = BTreeMap>>; type KeyframesMap = BTreeMap>>>; /// layer name -> Vec<(selector, property, value)> collected for `@layer` output. -type LayeredStyles = BTreeMap>; +type LayeredStyles = BTreeMap; fn deserialize_btree_map_u8<'de, D>( deserializer: D, @@ -507,9 +507,28 @@ impl StyleSheet { ExtractStyleValue::Static(st) if st.property() == "typography" => { let (StyleProperty::ClassName(class_name) | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope); + // `preset|property:level,...` lists the properties declared + // directly beside the preset and the breakpoint each starts at + let (preset, yielded) = st + .value() + .split_once('|') + .unwrap_or_else(|| (st.value(), "")); + let yielded: Vec<(&str, u8)> = yielded + .split(',') + .filter_map(|item| { + let (property, level) = item.split_once(':')?; + Some((property, level.parse().ok()?)) + }) + .collect(); for (level, property, value) in - self.theme.typography_declarations(st.value(), st.level()) + self.theme.typography_declarations(preset, st.level()) { + if yielded + .iter() + .any(|(yielded, from)| *yielded == property && level >= *from) + { + continue; + } if self.insert_property( level, st.style_order(), @@ -805,10 +824,38 @@ impl StyleSheet { // deep-cloning every `StyleSheetProperty` (3 owned `String`s + `Option`). // Each `prop` here is a `&P`; `.borrow()` yields the `&StyleSheetProperty` all downstream // buckets already hold. Output is byte-identical. + /// Layered properties are written with the same rules as the others, each + /// layer on its own: into `layered_styles` when given, else as trailing + /// `@layer` blocks. fn create_style_with_layers>( &self, map: &BTreeMap>, - layered_styles: Option<&mut LayeredStyles>, + mut layered_styles: Option<&mut LayeredStyles>, + ) -> String { + let mut css = self.create_layer_style(map, None); + let layers: BTreeSet<&str> = map + .values() + .flatten() + .filter_map(|prop| prop.borrow().layer.as_deref()) + .collect(); + for layer in layers { + let layer_css = self.create_layer_style(map, Some(layer)); + match layered_styles.as_deref_mut() { + Some(layered) => layered + .entry(layer.to_string()) + .or_default() + .push_str(&layer_css), + None => push_fmt!(&mut css, "@layer {layer}{{{layer_css}}}"), + } + } + css + } + + /// The properties of `map` in `layer` (`None`: in no layer) + fn create_layer_style>( + &self, + map: &BTreeMap>, + layer: Option<&str>, ) -> String { // Estimate ~64 bytes per property for pre-allocation let prop_count: usize = map.values().map(FxHashSet::len).sum(); @@ -820,6 +867,9 @@ impl StyleSheet { for (level, props) in map { for prop in props { let prop: &StyleSheetProperty = prop.borrow(); + if prop.layer.as_deref() != layer { + continue; + } match &prop.selector { Some(StyleSelector::Global(selector, _)) => { global_props.push((*level, selector.as_str(), prop)); @@ -861,7 +911,7 @@ impl StyleSheet { } } if !global_props.is_empty() { - self.write_global_props(&mut current_css, global_props, layered_styles); + self.write_global_props(&mut current_css, global_props); } // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint @@ -968,12 +1018,7 @@ impl StyleSheet { Some(rules.len()) } - fn write_global_props( - &self, - css: &mut String, - mut global_props: Vec>, - mut layered_styles: Option<&mut LayeredStyles>, - ) { + fn write_global_props(&self, css: &mut String, mut global_props: Vec>) { // Same order as class rules: selector group before breakpoint level, so a // `:hover` set at a wider breakpoint still precedes `:active`. global_props.sort_by(|a, b| { @@ -988,20 +1033,6 @@ impl StyleSheet { let mut open_level: Option = None; let mut open_selector: Option<&str> = None; for (level, selector, prop) in global_props { - if let (Some(layered_styles), Some(layer)) = - (layered_styles.as_deref_mut(), prop.layer.as_ref()) - { - let bucket = match layered_styles.get_mut(layer.as_str()) { - Some(bucket) => bucket, - None => layered_styles.entry(layer.clone()).or_default(), - }; - bucket.push(( - selector.to_string(), - prop.property.clone(), - prop.value.clone(), - )); - continue; - } if open_level != Some(level) { if open_selector.take().is_some() { css.push('}'); @@ -1204,24 +1235,8 @@ impl StyleSheet { } css.push(';'); - // Generate styles wrapped in @layer blocks - for (layer_name, styles) in layered_styles { - push_fmt!(&mut css, "@layer {layer_name}{{"); - let mut open_selector: Option<&str> = None; - for (selector, p, v) in &styles { - if open_selector == Some(selector.as_str()) { - css.push(';'); - } else { - if open_selector.is_some() { - css.push('}'); - } - css.push_str(selector); - css.push('{'); - open_selector = Some(selector); - } - push_fmt!(&mut css, "{p}:{v}"); - } - css.push_str("}}"); + for (layer_name, layer_css) in layered_styles { + push_fmt!(&mut css, "@layer {layer_name}{{{layer_css}}}"); } } // Atom hoisting: emit shared (hoisted) order!=0 atoms into the global @@ -1536,8 +1551,8 @@ mod tests { set_file_routes(previous_routes); assert!( - css.contains("@layer o2{div{border-radius:9px}}"), - "layered global hoist must emit direct CSS: {css}" + css.contains("@layer o2{@layer components{div{border-radius:9px}}}"), + "layered global hoist must keep its layer: {css}" ); } @@ -3416,6 +3431,25 @@ mod tests { } } + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_layer_pipeline() { + for (source, expected) in [ + // A layer keeps breakpoints, selectors and at-rules of its declarations. + ( + "globalCss({ '*': { '@layer': 'reset', margin: [0, null, 4], _hover: { color: ['red', null, 'blue'] }, _print: { color: 'black' } }, body: { color: 'white' } })", + "@layer b;@layer b{body{color:white}}@layer reset;@layer reset{*{margin:0}@media(min-width:768px){*{margin:16px}}*:hover{color:red}@media(min-width:768px){*:hover{color:blue}}@media print{*{color:black}}}", + ), + ( + "
", + ".c0{color:red}@layer base{.c1{color:blue}.c2{padding:4px}@media(min-width:768px){.c3{padding:8px}}.c4:hover{color:green}}@layer base.c5{.c6{margin:4px}}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + #[test] #[serial] #[allow(clippy::literal_string_with_formatting_args)] @@ -3442,15 +3476,52 @@ mod tests { "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}", ), ("", "@layer t;"), - // A declaration written next to the preset wins over the preset's. + // A declaration written next to the preset wins over the preset's, at + // every breakpoint from the one it starts at. ( "", - "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-size:11px}", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c2:hover{font-size:32px}}@media(min-width:992px){.c3:hover{font-size:11px}}", + ), + ( + "", + "@layer t;.c2{font-size:11px}@media(min-width:768px){.c3{font-family:var(--heading);font-weight:700}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-weight:700}.c3:hover{font-size:var(--c)}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}@media(min-width:768px){.c4:hover{font-size:12px}}", + ), + ( + "", + "@layer t;.c2:hover{font-family:var(--heading);font-size:20px;font-weight:700}.c3:hover{font-size:11px}.c4:hover{line-height:1}.c5:hover{line-height:2}@media(min-width:768px){.c2:hover{font-size:32px}}", + ), + ( + "", + "@layer t;.c2{bottom:0}.c3{left:0}.c4{right:0}.c5{top:0}.c6:hover{line-height:1.2}.c7:hover{font-family:var(--heading);font-weight:700}.c8:hover{font-size:11px}", + ), + ( + "", + "@layer t;.c2:hover{line-height:1.2}.c3:hover{font-family:var(--heading);font-weight:700}.c4:hover{font-size:11px}", ), ( "globalCss({ body: { typography: 'small' } })", "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}", ), + ( + "globalCss({ h1: { typography: 'title', fontSize: '13px' } })", + "@layer b,t;@layer b{h1{font-family:var(--heading);font-size:13px;font-weight:700}}", + ), ] { let mut theme = Theme::default(); theme.add_typography( From 8602befba1e69242855c4bae0eebd65022ff24f6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:24:49 +0900 Subject: [PATCH 007/109] docs: document library composition, vanilla-extract coverage and typography precedence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- SKILL.md | 2 +- .../landing/src/app/(detail)/docs/devup/typography/page.mdx | 2 +- .../app/(detail)/docs/migration/styled-components/page.mdx | 3 +++ .../app/(detail)/docs/migration/vanilla-extract/page.mdx | 6 ++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1279357f5..4b7f3c4f4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -150,7 +150,7 @@ All standard CSS properties from `csstype` are also accepted directly (e.g., `di Unitless CSS properties keep the number as written: `lineHeight`, `zIndex`, `opacity`, `fontWeight`, `flexGrow`, `order`, `aspectRatio`, `columnCount`, `strokeWidth`, `zoom`, and the rest of the unitless list vanilla-extract uses. -Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, and in `stylex.create()`, `fontSize: 16` is `16px`. +Styles from other libraries keep that library's number meaning instead of the scale: in `.css.ts` files, in vanilla-extract `style()`, `globalStyle()` and `keyframes()` calls inside ordinary modules, in Emotion and styled-components object styles, and in `stylex.create()`, `fontSize: 16` is `16px`. Devup UI shorthands (`p`, `bg`, ...) keep the scale everywhere. ### Responsive Arrays (5 breakpoints) diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx index 9df1f4f6f..e32fe65b4 100644 --- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx +++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx @@ -315,7 +315,7 @@ const Title = styled('h1', { ## Combining with Other Styles -Typography can be combined with other style props: +Typography can be combined with other style props. A property written next to `typography` wins over the preset at every breakpoint from the one it is set at, so `` stays `14px` even where `h1` grows: ```tsx const example = ( diff --git a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx index 52707e389..996ff9048 100644 --- a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx @@ -26,6 +26,8 @@ const E = styled(Component)({ bg: 'red' }) const F = styled('div', { bg: 'red' }) ``` +Object styles follow the libraries: several style arguments, or an array of them, merge into one rule set where a later declaration replaces an earlier one and nested rules merge; Emotion's options argument (`styled('div', { shouldForwardProp })(...)`) is accepted, and every prop is forwarded. Numbers mean pixels as they do in Emotion and styled-components — `padding: 8` is `8px`, unitless properties such as `lineHeight` stay as written — while Devup UI shorthands such as `p` keep the spacing scale. + Namespace imports work too. `@emotion/styled` and `styled-components` export a single callable, so the namespace binding _is_ that value and it is bound straight to the Devup UI export: ```tsx @@ -94,3 +96,4 @@ Emotion's `` behaves the same way: the `styles` prop is e | `ServerStyleSheet`, `StyleSheetManager` | inert — Devup UI already emits a real stylesheet, so there is nothing to collect | | `isStyledComponent` | always `false` | | `ClassNames`, `CacheProvider` | no equivalent; stays on its own package with a build warning | +| `.attrs()`, `.withConfig()` | not supported yet | diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index 09d5accc9..fc3c0bcc8 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -9,7 +9,7 @@ export const metadata = { ## Stylesheet files -Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `createVar`, `fallbackVar`, `createTheme`, `createThemeContract`, `layer`, `createContainer`. +Inside `.css.ts` / `.css.js` — the only place vanilla-extract allows its APIs — Devup UI evaluates the module and replaces every call with its generated output. The whole surface works there: `style`, `globalStyle`, `keyframes`, `styleVariants`, `fontFace`, `globalFontFace`, `createVar`, `fallbackVar`, `createTheme`, `createGlobalTheme`, `createThemeContract`, `createGlobalThemeContract`, `assignVars`, `layer`, `globalLayer`, `createContainer`. Generated names — custom properties, containers, layers, theme classes, font families — are unique to each stylesheet, and layers keep the order they are declared in. ```ts // theme.css.ts @@ -22,9 +22,11 @@ export const box = style({ background: vars.colors.bg }) Numbers keep vanilla-extract's meaning, here and in ordinary modules: `padding: 8` is `8px`, and unitless properties such as `lineHeight` or `zIndex` stay as written. +Each stylesheet is evaluated on its own, so importing values from another `.css.ts` file is not supported yet. + ## Ordinary modules -`style` and `globalStyle` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts: +`style`, `globalStyle` and `keyframes` also resolve in `.ts` / `.tsx`, mapped onto their Devup UI counterparts, and `style([base, { ... }])` keeps composing `base`: ```tsx import { globalStyle, style } from '@vanilla-extract/css' From e420a9b69369383ba98289ad26496351bc0dd045 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:26:41 +0900 Subject: [PATCH 008/109] chore: add changepack for compat, layer and typography fixes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_compat_layers_typography.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_compat_layers_typography.json diff --git a/.changepacks/changepack_log_compat_layers_typography.json b/.changepacks/changepack_log_compat_layers_typography.json new file mode 100644 index 000000000..eeb8eb641 --- /dev/null +++ b/.changepacks/changepack_log_compat_layers_typography.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", + "date": "2026-09-27T00:00:00.000Z" +} From f80441bb9e36e7a37eb00c33ddb34f5486417606 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 11:45:38 +0900 Subject: [PATCH 009/109] refactor(extractor): build font faces without a branching loop The loop left a region tarpaulin maps past the function on Linux; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2f7cc4262..7a561097f 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -771,18 +771,20 @@ fn declare_font_faces( rules: &JsValue, context: &mut Context, ) -> JsResult<()> { + let family = format!("\"fontFamily\":{}", json_string(family)); let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); - let family = json_string(family); - for rule in &rules { - let json = js_value_to_json(rule, context); - let inner = inner_json(&json); - let face = if inner.is_empty() { - format!("{{\"fontFamily\":{family}}}") - } else { - format!("{{{inner},\"fontFamily\":{family}}}") - }; - collector.borrow_mut().styles.font_faces.push(face); - } + let faces: Vec = rules + .iter() + .map(|rule| { + let json = js_value_to_json(rule, context); + let members: Vec<&str> = [inner_json(&json), family.as_str()] + .into_iter() + .filter(|member| !member.is_empty()) + .collect(); + format!("{{{}}}", members.join(",")) + }) + .collect(); + collector.borrow_mut().styles.font_faces.extend(faces); Ok(()) } From 3f4ab8508e331bc648ae44a076eb5dc8e33a4568 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:01:09 +0900 Subject: [PATCH 010/109] refactor(extractor): build font faces without closures Tarpaulin's Linux report still attributed a line past the function to its closures; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 7a561097f..43855459c 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -772,19 +772,20 @@ fn declare_font_faces( context: &mut Context, ) -> JsResult<()> { let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = array_items(rules, context)?.unwrap_or_else(|| vec![rules.clone()]); - let faces: Vec = rules - .iter() - .map(|rule| { - let json = js_value_to_json(rule, context); - let members: Vec<&str> = [inner_json(&json), family.as_str()] - .into_iter() - .filter(|member| !member.is_empty()) - .collect(); - format!("{{{}}}", members.join(",")) - }) - .collect(); - collector.borrow_mut().styles.font_faces.extend(faces); + let rules = match array_items(rules, context)? { + Some(items) => items, + None => vec![rules.clone()], + }; + for rule in &rules { + let json = js_value_to_json(rule, context); + let inner = inner_json(&json); + let face = if inner.is_empty() { + format!("{{{family}}}") + } else { + format!("{{{inner},{family}}}") + }; + collector.borrow_mut().styles.font_faces.push(face); + } Ok(()) } From d6582f65f06751f6d2c20394fa5131650a38b904 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:15:45 +0900 Subject: [PATCH 011/109] refactor(extractor): build the single font-face rule list without vec! The vec! expansion is the region tarpaulin maps past the function on Linux; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 43855459c..c8b8bab0b 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -774,7 +774,7 @@ fn declare_font_faces( let family = format!("\"fontFamily\":{}", json_string(family)); let rules = match array_items(rules, context)? { Some(items) => items, - None => vec![rules.clone()], + None => Vec::from([rules.clone()]), }; for rule in &rules { let json = js_value_to_json(rule, context); From 0a02c695d7b2b7409851b10f01662cda7b1dca05 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:31:52 +0900 Subject: [PATCH 012/109] refactor(extractor): make declaring font faces infallible Reading a rule list cannot fail, so the error path tarpaulin kept reporting as uncovered past the function is gone; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index c8b8bab0b..342fe78b6 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -591,7 +591,13 @@ fn register_vanilla_extract_apis( }), 1, ), - ("fontFace", api(collector, font_face), 1), + ( + "fontFace", + api(collector, |collector, args, context| { + Ok(font_face(collector, args, context)) + }), + 1, + ), ("globalFontFace", api(collector, global_font_face), 2), ("createVar", api(collector, create_var), 0), ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), @@ -743,16 +749,12 @@ fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context } /// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them -fn font_face( - collector: &StyleCollector, - args: &[JsValue], - context: &mut Context, -) -> JsResult { +fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - declare_font_faces(collector, &family, args.get_or_undefined(0), context)?; - Ok(js_string!(family).into()) + declare_font_faces(collector, &family, args.get_or_undefined(0), context); + js_string!(family).into() } fn global_font_face( @@ -761,7 +763,7 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - declare_font_faces(collector, &family, args.get_or_undefined(1), context)?; + declare_font_faces(collector, &family, args.get_or_undefined(1), context); Ok(JsValue::undefined()) } @@ -770,9 +772,9 @@ fn declare_font_faces( family: &str, rules: &JsValue, context: &mut Context, -) -> JsResult<()> { +) { let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = match array_items(rules, context)? { + let rules = match array_items(rules, context).ok().flatten() { Some(items) => items, None => Vec::from([rules.clone()]), }; @@ -786,7 +788,6 @@ fn declare_font_faces( }; collector.borrow_mut().styles.font_faces.push(face); } - Ok(()) } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a From 030461032c716d3c7387a6a4c4d6e087b05a9c49 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 12:46:20 +0900 Subject: [PATCH 013/109] chore(extractor): keep the font-face loop out of tarpaulin's report Its output is asserted by test_font_faces; tarpaulin's Linux report maps the loop exit onto a line it never counts however the loop is written. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 342fe78b6..139ce64eb 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -767,6 +767,9 @@ fn global_font_face( Ok(JsValue::undefined()) } +// `test_font_faces` pins the output; tarpaulin's Linux report maps this loop's +// exit onto a line it never counts, whichever way the loop is written +#[cfg(not(tarpaulin_include))] fn declare_font_faces( collector: &StyleCollector, family: &str, From 01db1ba96302da09f0a65f1f60be400b8f0e7083 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:00:53 +0900 Subject: [PATCH 014/109] refactor(extractor): declare font-face lists recursively Shaped like compose so tarpaulin's Linux report maps its loop, and the coverage exclusion is gone again; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 139ce64eb..95989152f 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -753,7 +753,8 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - declare_font_faces(collector, &family, args.get_or_undefined(0), context); + let member = format!("\"fontFamily\":{}", json_string(&family)); + declare_font_faces(collector, &member, args.get_or_undefined(0), context); js_string!(family).into() } @@ -763,25 +764,23 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - declare_font_faces(collector, &family, args.get_or_undefined(1), context); + let member = format!("\"fontFamily\":{}", json_string(&family)); + declare_font_faces(collector, &member, args.get_or_undefined(1), context); Ok(JsValue::undefined()) } -// `test_font_faces` pins the output; tarpaulin's Linux report maps this loop's -// exit onto a line it never counts, whichever way the loop is written -#[cfg(not(tarpaulin_include))] +/// Declare `rule`, or each rule of a list, with the `fontFamily` member `family` fn declare_font_faces( collector: &StyleCollector, family: &str, - rules: &JsValue, + rule: &JsValue, context: &mut Context, ) { - let family = format!("\"fontFamily\":{}", json_string(family)); - let rules = match array_items(rules, context).ok().flatten() { - Some(items) => items, - None => Vec::from([rules.clone()]), - }; - for rule in &rules { + if let Some(rules) = array_items(rule, context).ok().flatten() { + for rule in &rules { + declare_font_faces(collector, family, rule, context); + } + } else { let json = js_value_to_json(rule, context); let inner = inner_json(&json); let face = if inner.is_empty() { From 5932d21faeb557f918845f788871cb5b880c8fd6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:16:54 +0900 Subject: [PATCH 015/109] refactor(extractor): build font-face rules without touching the collector The callers store the rules, so the helper ends in its value; the output is unchanged. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 46 ++++++++++++--------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 95989152f..23fcb3bf4 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -753,8 +753,8 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - let member = format!("\"fontFamily\":{}", json_string(&family)); - declare_font_faces(collector, &member, args.get_or_undefined(0), context); + let faces = font_face_rules(&family, args.get_or_undefined(0), context); + collector.borrow_mut().styles.font_faces.extend(faces); js_string!(family).into() } @@ -764,32 +764,28 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - let member = format!("\"fontFamily\":{}", json_string(&family)); - declare_font_faces(collector, &member, args.get_or_undefined(1), context); + let faces = font_face_rules(&family, args.get_or_undefined(1), context); + collector.borrow_mut().styles.font_faces.extend(faces); Ok(JsValue::undefined()) } -/// Declare `rule`, or each rule of a list, with the `fontFamily` member `family` -fn declare_font_faces( - collector: &StyleCollector, - family: &str, - rule: &JsValue, - context: &mut Context, -) { - if let Some(rules) = array_items(rule, context).ok().flatten() { - for rule in &rules { - declare_font_faces(collector, family, rule, context); - } - } else { - let json = js_value_to_json(rule, context); - let inner = inner_json(&json); - let face = if inner.is_empty() { - format!("{{{family}}}") - } else { - format!("{{{inner},{family}}}") - }; - collector.borrow_mut().styles.font_faces.push(face); - } +/// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` +fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { + let rules = array_items(rule, context) + .ok() + .flatten() + .unwrap_or_else(|| Vec::from([rule.clone()])); + let family = json_string(family); + rules + .iter() + .map(|rule| { + let json = js_value_to_json(rule, context); + match inner_json(&json) { + "" => format!("{{\"fontFamily\":{family}}}"), + inner => format!("{{{inner},\"fontFamily\":{family}}}"), + } + }) + .collect() } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a From 01ecb4cbb4c20a74387b4301e3a488e9d7141ee0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:30:32 +0900 Subject: [PATCH 016/109] test(extractor): extract font faces through the library build Tarpaulin merges the unit-test build and the library build that other targets link; the latter never ran the font-face helper, which left one region at zero. The helper is split into single-rule and list parts. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 32 ++++++++++++++----------- libs/extractor/tests/vanilla_extract.rs | 32 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 libs/extractor/tests/vanilla_extract.rs diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 23fcb3bf4..2314b3eda 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -769,23 +769,27 @@ fn global_font_face( Ok(JsValue::undefined()) } +fn font_face_rule(family_json: &str, rule: &JsValue, context: &mut Context) -> String { + let json = js_value_to_json(rule, context); + match inner_json(&json) { + "" => format!("{{\"fontFamily\":{family_json}}}"), + inner => format!("{{{inner},\"fontFamily\":{family_json}}}"), + } +} + /// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { - let rules = array_items(rule, context) - .ok() - .flatten() - .unwrap_or_else(|| Vec::from([rule.clone()])); - let family = json_string(family); - rules - .iter() - .map(|rule| { - let json = js_value_to_json(rule, context); - match inner_json(&json) { - "" => format!("{{\"fontFamily\":{family}}}"), - inner => format!("{{{inner},\"fontFamily\":{family}}}"), + let family_json = json_string(family); + match array_items(rule, context).ok().flatten() { + Some(rules) => { + let mut faces = Vec::with_capacity(rules.len()); + for rule in &rules { + faces.push(font_face_rule(&family_json, rule, context)); } - }) - .collect() + faces + } + None => vec![font_face_rule(&family_json, rule, context)], + } } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs new file mode 100644 index 000000000..3328046af --- /dev/null +++ b/libs/extractor/tests/vanilla_extract.rs @@ -0,0 +1,32 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use extractor::{ExtractOption, extract}; + +#[test] +fn font_faces_extract_through_the_library() { + let output = extract( + "fonts.css.ts", + r"import { fontFace, globalFontFace, style } from '@devup-ui/react' +const body = fontFace({ src: 'local(a)' }, 'Body') +const icons = fontFace([{ src: 'local(b)' }, {}]) +const cyclic = {}; cyclic.self = cyclic +fontFace(cyclic) +globalFontFace('Inter', { src: 'local(Inter)' }) +export const text = style({ fontFamily: body, content: icons })", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + let styles = format!("{:?}", output.styles); + for family in ["Body-", "font-", "Inter"] { + assert!(styles.contains(family), "{family} missing from {styles}"); + } + assert!(styles.contains("local(b)"), "{styles}"); +} From 56c525296f0c0c7a16a3c3924243b31ec9433ddc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:46:22 +0900 Subject: [PATCH 017/109] fix(extractor): keep custom property values verbatim A number on a custom property was read as the spacing scale (--columns: 4 became 16px), which broke theme tokens and var() math. The vanilla-extract API now also runs through the library build in an integration test. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...angepack_log_compat_layers_typography.json | 2 +- .../src/extract_style/extract_static_style.rs | 33 ++++++---- libs/extractor/tests/vanilla_extract.rs | 65 +++++++++++++++---- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/.changepacks/changepack_log_compat_layers_typography.json b/.changepacks/changepack_log_compat_layers_typography.json index eeb8eb641..88b73bad8 100644 --- a/.changepacks/changepack_log_compat_layers_typography.json +++ b/.changepacks/changepack_log_compat_layers_typography.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", + "note": "Fix vanilla-extract stylesheets: styles are named after the variable holding them, so style calls nested in objects no longer shift every later name onto the wrong styles and such objects export their classes; createVar, createContainer, layer, fontFace and theme variables get names unique to each stylesheet instead of colliding across files, createVar returns var() so template literals and fallbackVar work, createTheme(tokens, debugId), the three-argument createGlobalTheme and unassigned createGlobalTheme calls produce their variables, createThemeContract exports, styleVariants variants resolve in selectors and compositions and accept a map function, composition keeps transitive bases, '@layer' records, globalLayer, createGlobalThemeContract, assignVars and globalFontFace work, and layers keep their declaration order. Emotion and styled-components object styles read numbers as pixels (Devup UI shorthands keep the spacing scale), compose several style arguments or arrays, and accept Emotion's styled(tag, options) factory; css() composes class names passed with rule objects. A global '@layer' now covers responsive, selector and at-rule declarations, and layered declarations keep their breakpoints. Custom properties keep their value instead of reading a number as the spacing scale. A typography preset no longer overrides a property written beside it at a wider breakpoint, in components and globalCss", "date": "2026-09-27T00:00:00.000Z" } diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 91ad74822..12d3249ba 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -65,22 +65,23 @@ impl ExtractStaticStyle { // immediately re-copied by `optimize_value` (which takes `&str` and owns its // own result). Only the aspect-ratio reduction and the `convert_value` branch // must own; both already produce owned `String`s. Byte-identical output. - let normalized: Cow = if MAINTAIN_VALUE_PROPERTIES.contains(property) { - if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { - if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { - Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) - }) { - let gcd = gcd(a, b); - Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + let normalized: Cow = + if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") { + if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { + if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { + Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) + }) { + let gcd = gcd(a, b); + Cow::Owned(format!("{}/{}", a / gcd, b / gcd)) + } else { + Cow::Borrowed(value) + } } else { Cow::Borrowed(value) } } else { - Cow::Borrowed(value) - } - } else { - convert_value(value) - }; + convert_value(value) + }; // `optimize_value` returns `Cow` (borrowed when no optimization pass // fires); this constructor stores an owned `String`, so materialize it // here — a borrowed result costs exactly the one copy it always did. @@ -210,6 +211,14 @@ mod tests { assert_eq!(style.selector(), None); assert_eq!(style.style_order(), None); assert_eq!(style.layer(), None); + assert_eq!( + ExtractStaticStyle::new("--columns", "4", 0, None).value(), + "4" + ); + assert_eq!( + ExtractStaticStyle::new("padding", "4", 0, None).value(), + "16px" + ); } #[test] diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index 3328046af..cf1f54a29 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -4,17 +4,10 @@ use std::collections::HashMap; use extractor::{ExtractOption, extract}; -#[test] -fn font_faces_extract_through_the_library() { +fn extract_stylesheet(code: &str) -> String { let output = extract( - "fonts.css.ts", - r"import { fontFace, globalFontFace, style } from '@devup-ui/react' -const body = fontFace({ src: 'local(a)' }, 'Body') -const icons = fontFace([{ src: 'local(b)' }, {}]) -const cyclic = {}; cyclic.self = cyclic -fontFace(cyclic) -globalFontFace('Inter', { src: 'local(Inter)' }) -export const text = style({ fontFamily: body, content: icons })", + "stylesheet.css.ts", + &format!("import {{ style, globalStyle, styleVariants, keyframes, fontFace, globalFontFace, createVar, fallbackVar, createContainer, layer, globalLayer, createTheme, createGlobalTheme, createThemeContract, createGlobalThemeContract, assignVars }} from '@devup-ui/react'\n{code}"), ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -24,9 +17,53 @@ export const text = style({ fontFamily: body, content: icons })", }, ) .unwrap(); - let styles = format!("{:?}", output.styles); - for family in ["Body-", "font-", "Inter"] { - assert!(styles.contains(family), "{family} missing from {styles}"); + format!("{:?}\n{}", output.styles, output.code) +} + +// The library build other targets link is a separate copy from the unit-test +// build; these run every vanilla-extract API through it. +#[test] +fn font_faces_extract_through_the_library() { + let output = extract_stylesheet( + r"const body = fontFace({ src: 'local(a)' }, 'Body') +const icons = fontFace([{ src: 'local(b)' }, {}]) +const cyclic = {}; cyclic.self = cyclic +fontFace(cyclic) +globalFontFace('Inter', { src: 'local(Inter)' }) +export const text = style({ fontFamily: body, content: icons })", + ); + for family in ["Body-", "font-", "Inter", "local(b)"] { + assert!(output.contains(family), "{family} missing from {output}"); + } +} + +#[test] +fn vars_themes_and_layers_extract_through_the_library() { + let output = extract_stylesheet( + r"const plain = createVar() +const typed = createVar({ syntax: ['', ''], inherits: false, initialValue: '0px' }, 'size') +export const box = style({ vars: { [plain]: '1px' }, width: fallbackVar(typed, plain, '2px'), containerName: createContainer('side') }) +const contract = createThemeContract({ color: null }) +export const theme = createTheme(contract, { color: 'red' }) +export const [light, lightVars] = createTheme({ '@layer': layer('theme'), space: 4 }) +createGlobalTheme(':root', createGlobalThemeContract({ gap: 'gap' }), { gap: '1px' }) +export const assigned = style({ vars: assignVars(contract, { color: 'blue' }) }) +export const variants = styleVariants({ a: { color: 'red' } }, (rule) => [box, rule]) +globalStyle(`${variants.a} span`, { '@layer': { [globalLayer({ parent: 'base' }, 'x')]: { margin: 1 } } }) +export const spin = keyframes({ to: { opacity: 1 } })", + ); + for expected in [ + "@property --size-", + "var(--size-", + "container-name", + "theme-", + "property: \"--gap\", value: \"1px\"", + "value: \"4\", level: 0, selector: Some(Global(\".theme-", + "base.x", + ] { + assert!( + output.contains(expected), + "{expected} missing from {output}" + ); } - assert!(styles.contains("local(b)"), "{styles}"); } From ac08fe354ef61021ad371a03477df623720948dc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 13:59:34 +0900 Subject: [PATCH 018/109] test(extractor): cover a createVar declaration that cannot be read Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 8 ++++++++ libs/extractor/tests/vanilla_extract.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 2314b3eda..029bbd337 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -1694,6 +1694,14 @@ export const notObject = ["theme-0-9", {}]"# assert_eq!(generate("const x = 1"), ""); reset_file_map(); assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); + assert!( + execute_vanilla_extract( + "import { createVar } from '@devup-ui/react'\ncreateVar({ syntax: Symbol() })", + PACKAGE, + "test.css.ts" + ) + .is_err() + ); } #[test] diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index cf1f54a29..7ebca73f1 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -37,6 +37,24 @@ export const text = style({ fontFamily: body, content: icons })", } } +#[test] +fn an_unreadable_var_declaration_leaves_the_stylesheet_alone() { + let code = "import { createVar } from '@devup-ui/react'\nexport const v = createVar({ syntax: Symbol() })"; + let output = extract( + "broken.css.ts", + code, + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + assert!(output.styles.is_empty()); +} + #[test] fn vars_themes_and_layers_extract_through_the_library() { let output = extract_stylesheet( From 86dbff12bdd0912182f0ab371cfac3b8f9e45159 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:12:22 +0900 Subject: [PATCH 019/109] refactor(extractor): fit the createVar signature on one line Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 029bbd337..6118e109a 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -794,11 +794,7 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { +fn create_var(styles: &StyleCollector, args: &[JsValue], ctx: &mut Context) -> JsResult { let first = args.get_or_undefined(0); let (declaration, debug_id) = match first.as_object() { Some(declaration) => (Some(declaration), args.get_or_undefined(1)), @@ -806,11 +802,11 @@ fn create_var( }; let name = format!( "--{}", - collector.borrow_mut().identifier(js_str(debug_id), "var") + styles.borrow_mut().identifier(js_str(debug_id), "var") ); if let Some(declaration) = declaration { - let rule = property_rule(&name, &declaration, context)?; - collector.borrow_mut().styles.property_rules.push(rule); + let rule = property_rule(&name, &declaration, ctx)?; + styles.borrow_mut().styles.property_rules.push(rule); } Ok(js_string!(format!("var({name})")).into()) } From 46e25f4889eee4b9a866b399b08f0f6a30adbf4c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:33:14 +0900 Subject: [PATCH 020/109] refactor(extractor): split createVar into name and reference helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 6118e109a..c1f3b3db1 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -796,19 +796,24 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { let first = args.get_or_undefined(0); - let (declaration, debug_id) = match first.as_object() { - Some(declaration) => (Some(declaration), args.get_or_undefined(1)), - None => (None, first), + let Some(declaration) = first.as_object() else { + return Ok(var_reference(&new_var(styles, first))); }; - let name = format!( + let name = new_var(styles, args.get_or_undefined(1)); + let rule = property_rule(&name, &declaration, ctx)?; + styles.borrow_mut().styles.property_rules.push(rule); + Ok(var_reference(&name)) +} + +fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { + format!( "--{}", styles.borrow_mut().identifier(js_str(debug_id), "var") - ); - if let Some(declaration) = declaration { - let rule = property_rule(&name, &declaration, ctx)?; - styles.borrow_mut().styles.property_rules.push(rule); - } - Ok(js_string!(format!("var({name})")).into()) + ) +} + +fn var_reference(name: &str) -> JsValue { + js_string!(format!("var({name})")).into() } fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { From ba9842029072a30b2d828273928a81c0a8e2d9ab Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 14:47:35 +0900 Subject: [PATCH 021/109] refactor(extractor): move the declared createVar branch into its own function Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index c1f3b3db1..b9a55e099 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -796,13 +796,23 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { let first = args.get_or_undefined(0); - let Some(declaration) = first.as_object() else { - return Ok(var_reference(&new_var(styles, first))); - }; - let name = new_var(styles, args.get_or_undefined(1)); - let rule = property_rule(&name, &declaration, ctx)?; - styles.borrow_mut().styles.property_rules.push(rule); - Ok(var_reference(&name)) + match first.as_object() { + Some(declaration) => declared_var(styles, &declaration, args.get_or_undefined(1), ctx), + None => Ok(var_reference(&new_var(styles, first))), + } +} + +fn declared_var( + styles: &StyleCollector, + declaration: &JsObject, + debug_id: &JsValue, + ctx: &mut Context, +) -> JsResult { + let name = new_var(styles, debug_id); + property_rule(&name, declaration, ctx).map(|rule| { + styles.borrow_mut().styles.property_rules.push(rule); + var_reference(&name) + }) } fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { From af6427b43f8a4561db32f08275a56702d46f9802 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 15:13:05 +0900 Subject: [PATCH 022/109] refactor(extractor): keep the declared createVar helper out of line Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index b9a55e099..e919367d6 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -802,6 +802,7 @@ fn create_var(styles: &StyleCollector, args: &[JsValue], ctx: &mut Context) -> J } } +#[inline(never)] fn declared_var( styles: &StyleCollector, declaration: &JsObject, @@ -809,10 +810,13 @@ fn declared_var( ctx: &mut Context, ) -> JsResult { let name = new_var(styles, debug_id); - property_rule(&name, declaration, ctx).map(|rule| { - styles.borrow_mut().styles.property_rules.push(rule); - var_reference(&name) - }) + match property_rule(&name, declaration, ctx) { + Ok(rule) => { + styles.borrow_mut().styles.property_rules.push(rule); + Ok(var_reference(&name)) + } + Err(error) => Err(error), + } } fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { From 8262312351d7fa4b06d3c095ca78b94a0c8cfdef Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 16:05:20 +0900 Subject: [PATCH 023/109] fix(extractor): resolve placeholders through one selector function type resolve took impl Fn, so the always-selector caller got its own copy whose plain class-name branch never runs; CI coverage reported that branch as uncovered. A fn pointer keeps one copy. createVar returns to its original shape. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract.rs | 51 ++++++++++----------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index e919367d6..d7f601f56 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -794,40 +794,25 @@ fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec JsResult { - let first = args.get_or_undefined(0); - match first.as_object() { - Some(declaration) => declared_var(styles, &declaration, args.get_or_undefined(1), ctx), - None => Ok(var_reference(&new_var(styles, first))), - } -} - -#[inline(never)] -fn declared_var( - styles: &StyleCollector, - declaration: &JsObject, - debug_id: &JsValue, - ctx: &mut Context, +fn create_var( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - let name = new_var(styles, debug_id); - match property_rule(&name, declaration, ctx) { - Ok(rule) => { - styles.borrow_mut().styles.property_rules.push(rule); - Ok(var_reference(&name)) - } - Err(error) => Err(error), - } -} - -fn new_var(styles: &StyleCollector, debug_id: &JsValue) -> String { - format!( + let first = args.get_or_undefined(0); + let (declaration, debug_id) = match first.as_object() { + Some(declaration) => (Some(declaration), args.get_or_undefined(1)), + None => (None, first), + }; + let name = format!( "--{}", - styles.borrow_mut().identifier(js_str(debug_id), "var") - ) -} - -fn var_reference(name: &str) -> JsValue { - js_string!(format!("var({name})")).into() + collector.borrow_mut().identifier(js_str(debug_id), "var") + ); + if let Some(declaration) = declaration { + let rule = property_rule(&name, &declaration, context)?; + collector.borrow_mut().styles.property_rules.push(rule); + } + Ok(js_string!(format!("var({name})")).into()) } fn property_rule(name: &str, declaration: &JsObject, context: &mut Context) -> JsResult { @@ -1169,7 +1154,7 @@ impl CollectedStyles { &self, source: &str, keyframes_names: &FxHashMap, - selector: impl Fn(&str) -> bool, + selector: fn(&str) -> bool, ) -> String { replace_placeholders(source, |token, rest| { Some(match self.references.get(token)? { From 75397db715d8cf75698d3f0fc714554d08cfdd11 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 16:22:56 +0900 Subject: [PATCH 024/109] test(extractor): resolve a style reference outside a selector through the library Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/tests/vanilla_extract.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index 7ebca73f1..0f1268a81 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -68,9 +68,11 @@ createGlobalTheme(':root', createGlobalThemeContract({ gap: 'gap' }), { gap: '1p export const assigned = style({ vars: assignVars(contract, { color: 'blue' }) }) export const variants = styleVariants({ a: { color: 'red' } }, (rule) => [box, rule]) globalStyle(`${variants.a} span`, { '@layer': { [globalLayer({ parent: 'base' }, 'x')]: { margin: 1 } } }) -export const spin = keyframes({ to: { opacity: 1 } })", +export const spin = keyframes({ to: { opacity: 1 } }) +export const named = style({ content: `'${box}'` })", ); for expected in [ + "_box'\", level: 0, selector: None", "@property --size-", "var(--size-", "container-name", From 93e5a7be146c4c3f06840ea191578a4b157c5669 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 17:04:27 +0900 Subject: [PATCH 025/109] feat(extractor): support styled-components attrs and withConfig, and every styled import styled.x.attrs(a).withConfig(c) now extracts like styled.x: withConfig is dropped and the attrs are merged over the props in order, objects spread and functions called with the props. A file importing styled from several libraries extracts all of them instead of only the last. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../extractor/extract_style_from_styled.rs | 197 +++++++++++++++++- libs/extractor/src/lib.rs | 42 ++++ ...and_styled_components_object_styles-2.snap | 22 +- ...ponents_attrs_and_every_styled_import.snap | 59 ++++++ libs/extractor/src/visit.rs | 32 ++- 5 files changed, 330 insertions(+), 22 deletions(-) create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index bad073af9..797215a92 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -13,7 +13,7 @@ use crate::{ gen_style::gen_styles, utils::{ StyleArguments, merge_object_expressions, style_arguments, unwrap_syntax_only, - wrap_array_filter, + unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -21,11 +21,12 @@ use oxc_ast::{ ast::{ Argument, BindingPattern, BindingProperty, BindingRestElement, CallExpression, Expression, FormalParameter, FormalParameterKind, FormalParameters, JSXAttributeItem, JSXAttributeName, - JSXAttributeValue, JSXElementName, JSXOpeningElement, PropertyKey, Str, + JSXAttributeValue, JSXElementName, JSXOpeningElement, ObjectPropertyKind, PropertyKey, Str, }, builder::AstBuilder, }; use oxc_span::SPAN; +use oxc_syntax::operator::{BinaryOperator, UnaryOperator}; fn extract_base_tag_and_class_name( input: &Expression<'_>, @@ -106,6 +107,7 @@ pub fn extract_style_from_styled<'a>( expression: &mut Expression<'a>, split_filename: Option<&str>, imports: &FxHashMap, + attrs: &[Expression<'a>], ) -> (ExtractResult<'a>, Expression<'a>) { if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(&call.callee, imports) @@ -136,11 +138,15 @@ pub fn extract_style_from_styled<'a>( } let class_name = gen_class_names(ast_builder, &mut props_styles, None, split_filename); - let styled_component = create_styled_component( + let styled_component = apply_attrs( ast_builder, - &tag_name, - &class_name, - &gen_styles(ast_builder, &props_styles, None), + create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &props_styles, None), + ), + attrs, ); let result = ExtractResult { @@ -188,11 +194,15 @@ pub fn extract_style_from_styled<'a>( } let class_name = gen_class_names(ast_builder, &mut styles, style_order, split_filename); - let styled_component = create_styled_component( + let styled_component = apply_attrs( ast_builder, - &tag_name, - &class_name, - &gen_styles(ast_builder, &styles, None), + create_styled_component( + ast_builder, + &tag_name, + &class_name, + &gen_styles(ast_builder, &styles, None), + ), + attrs, ); let result = ExtractResult { @@ -213,6 +223,173 @@ pub fn extract_style_from_styled<'a>( ) } +/// The name the attrs wrapper binds props to, chosen not to shadow what the +/// attrs expressions read +const ATTRS_PROPS: &str = "__devupProps"; + +/// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory +/// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the +/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped. +pub fn take_styled_modifiers<'a>( + ast_builder: &AstBuilder<'a>, + factory: &mut Expression<'a>, + is_styled: impl Fn(&str) -> bool, +) -> Vec> { + let mut attrs = Vec::new(); + if !is_modified_styled(factory, is_styled) { + return attrs; + } + while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory) + && let CallExpression { + callee, arguments, .. + } = &mut **call + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only_mut(callee) + && matches!(member.property.name.as_str(), "attrs" | "withConfig") + { + let placeholder = || Expression::new_null_literal(SPAN, ast_builder); + if member.property.name == "attrs" + && let Some(argument) = arguments[0].as_expression_mut() + { + attrs.push(std::mem::replace(argument, placeholder())); + } + let object = std::mem::replace(&mut member.object, placeholder()); + *factory = object; + } + attrs.reverse(); + attrs +} + +fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool { + let mut expression = unwrap_syntax_only(expression); + let mut modified = false; + while let Some(object) = modifier_object(expression) { + expression = unwrap_syntax_only(object); + modified = true; + } + modified + && match expression { + Expression::StaticMemberExpression(member) => { + matches!(&member.object, Expression::Identifier(ident) if is_styled(&ident.name)) + } + Expression::CallExpression(call) => { + matches!(&call.callee, Expression::Identifier(ident) if is_styled(&ident.name)) + } + _ => false, + } +} + +fn modifier_object<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + if let Expression::CallExpression(call) = expression + && let [argument] = call.arguments.as_slice() + && argument.is_expression() + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only(&call.callee) + && matches!(member.property.name.as_str(), "attrs" | "withConfig") + { + Some(&member.object) + } else { + None + } +} + +/// Wrap `component` so the attrs are merged over its props first: an object is +/// spread, anything else is called with the props when it is a function, as +/// styled-components does +fn apply_attrs<'a>( + ast_builder: &AstBuilder<'a>, + component: Expression<'a>, + attrs: &[Expression<'a>], +) -> Expression<'a> { + if attrs.is_empty() { + return component; + } + let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); + let mut merged = props(); + for attr in attrs { + let attr = attr.clone_in(ast_builder.allocator()); + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + merged = spread_objects(ast_builder, merged, attr); + continue; + } + let called = wrap_direct_call(ast_builder, &attr, &[props()]); + let resolved = if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + called + } else { + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) + }; + let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + merged = wrap_direct_call(ast_builder, &step, &[merged]); + } + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +fn spread_objects<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let mut properties = oxc_allocator::Vec::with_capacity_in(2, ast_builder); + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + first, + ast_builder, + )); + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + second, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + +fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + let parameter = FormalParameter::new( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + None::>>, + None::>>, + false, + None, + false, + false, + ast_builder, + ); + let params = FormalParameters::boxed( + SPAN, + FormalParameterKind::ArrowFormalParameters, + oxc_allocator::Vec::from_iter_in([parameter], ast_builder), + None::>>, + ast_builder, + ); + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + params, + None::>>, + body.into(), + ast_builder, + ) +} + fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, tag_name: &str, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 512c050f1..cab0b239a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -17907,6 +17907,48 @@ export const j = css({ height: 2 });", } } + #[test] + #[serial] + fn test_styled_components_attrs_and_every_styled_import() { + let aliases = HashMap::from([ + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ( + "styled-components".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]); + reset_class_map(); + reset_file_map(); + assert_debug_snapshot!(ToBTreeSet::from( + extract( + "test.tsx", + r"import emotion from '@emotion/styled'; +import sc from 'styled-components'; +export const A = emotion.div({ top: 1 }); +export const B = sc.span({ left: 2 }); +export const C = sc.button.attrs({ type: 'button' }).withConfig({ displayName: 'c' })({ right: 3 }); +export const D = sc(Base).withConfig({ shouldForwardProp: () => true })`color: red;`; +export const E = (sc.input.attrs((props) => ({ size: props.small ? 5 : 10 })) as any).attrs(extra)({ bottom: 4 }); +export const F = sc.div.attrs({ role: 'note' }); +export const G = other.div.attrs({ role: 'note' })({ margin: 1 }); +export const H = sc.div.attrs(...rest)({ margin: 2 }); +export const I = factory.attrs({ id: 'i' })({ margin: 3 }); +export const J = other(Base).attrs({ id: 'j' })({ margin: 4 });", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: aliases, + }, + ) + .unwrap() + )); + } + #[test] #[serial] fn test_composed_css_keeps_composed_classes() { diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap index 30712f964..c5b7c96d9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -14,6 +14,26 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "left", + value: "9px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "7px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "width", @@ -25,5 +45,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = Styled.div.attrs({ type: \"button\" }).withConfig({ displayName: \"g\" })({ right: \"7px\" });\nexport const H = Styled(Base).withConfig({ displayName: \"h\" })({ left: \"9px\" });\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"b\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const G = (__devupProps) => (({ style, className, ...rest }) =>
)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n});\nexport const H = ({ style, className, ...rest }) => ;\nexport const I = ({ style, className, ...rest }) =>
;\nexport const j = \"d\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap new file mode 100644 index 000000000..75e735921 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap @@ -0,0 +1,59 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import emotion from '@emotion/styled';\nimport sc from 'styled-components';\nexport const A = emotion.div({ top: 1 });\nexport const B = sc.span({ left: 2 });\nexport const C = sc.button.attrs({ type: 'button' }).withConfig({ displayName: 'c' })({ right: 3 });\nexport const D = sc(Base).withConfig({ shouldForwardProp: () => true })`color: red;`;\nexport const E = (sc.input.attrs((props) => ({ size: props.small ? 5 : 10 })) as any).attrs(extra)({ bottom: 4 });\nexport const F = sc.div.attrs({ role: 'note' });\nexport const G = other.div.attrs({ role: 'note' })({ margin: 1 });\nexport const H = sc.div.attrs(...rest)({ margin: 2 });\nexport const I = factory.attrs({ id: 'i' })({ margin: 3 });\nexport const J = other(Base).attrs({ id: 'j' })({ margin: 4 });\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: aliases,\n},).unwrap())" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "bottom", + value: "16px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "left", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "right", + value: "3px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "top", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui.css\";\nexport const A = ({ style, className, ...rest }) =>
;\nexport const B = ({ style, className, ...rest }) => ;\nexport const C = (__devupProps) => (({ style, className, ...rest }) =>