Skip to content

Commit 13d4660

Browse files
committed
Support createGlobalTheme
1 parent 48d40fe commit 13d4660

2 files changed

Lines changed: 260 additions & 5 deletions

File tree

libs/extractor/src/snapshots/extractor__tests__vanilla_extract_global_theme.snap

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,71 @@ source: libs/extractor/src/lib.rs
33
expression: "ToBTreeSet::from(extract(\"global-theme.css.ts\",\nr#\"import { createGlobalTheme } from '@devup-ui/react'\nexport const vars = createGlobalTheme(':root', {\n color: {\n brand: 'blue',\n text: 'black',\n background: 'white'\n },\n font: {\n body: 'system-ui, sans-serif'\n }\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}).unwrap())"
44
---
55
ToBTreeSet {
6-
styles: {},
7-
code: "export const vars = createGlobalTheme(\":root\", {\n\tcolor: {\n\t\tbrand: \"blue\",\n\t\ttext: \"black\",\n\t\tbackground: \"white\"\n\t},\n\tfont: { body: \"system-ui, sans-serif\" }\n});\n",
6+
styles: {
7+
Static(
8+
ExtractStaticStyle {
9+
property: "--color-background-global_theme_0-2",
10+
value: "white",
11+
level: 0,
12+
selector: Some(
13+
Global(
14+
":root",
15+
"global-theme.css.ts",
16+
),
17+
),
18+
style_order: Some(
19+
0,
20+
),
21+
},
22+
),
23+
Static(
24+
ExtractStaticStyle {
25+
property: "--color-brand-global_theme_0-0",
26+
value: "blue",
27+
level: 0,
28+
selector: Some(
29+
Global(
30+
":root",
31+
"global-theme.css.ts",
32+
),
33+
),
34+
style_order: Some(
35+
0,
36+
),
37+
},
38+
),
39+
Static(
40+
ExtractStaticStyle {
41+
property: "--color-text-global_theme_0-1",
42+
value: "black",
43+
level: 0,
44+
selector: Some(
45+
Global(
46+
":root",
47+
"global-theme.css.ts",
48+
),
49+
),
50+
style_order: Some(
51+
0,
52+
),
53+
},
54+
),
55+
Static(
56+
ExtractStaticStyle {
57+
property: "--font-body-global_theme_0-3",
58+
value: "system-ui,sans-serif",
59+
level: 0,
60+
selector: Some(
61+
Global(
62+
":root",
63+
"global-theme.css.ts",
64+
),
65+
),
66+
style_order: Some(
67+
0,
68+
),
69+
},
70+
),
71+
},
72+
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",
873
}

libs/extractor/src/vanilla_extract.rs

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ pub struct StyleEntry {
4141
pub bases: Vec<String>,
4242
}
4343

44+
/// Entry for createGlobalTheme() - CSS variables scoped to a selector
45+
#[derive(Debug, Clone, Default)]
46+
pub struct GlobalThemeEntry {
47+
/// CSS selector (e.g., ":root")
48+
pub selector: String,
49+
/// CSS variables: Vec<(var_name, value)> e.g. [("--color-brand-0-0", "blue")]
50+
pub css_vars: Vec<(String, String)>,
51+
/// Serialized JS object with var() references
52+
pub vars_object_json: String,
53+
/// Whether this is exported
54+
pub exported: bool,
55+
}
56+
4457
/// Collected style definitions from vanilla-extract API calls
4558
#[derive(Debug, Default)]
4659
pub struct CollectedStyles {
@@ -60,6 +73,8 @@ pub struct CollectedStyles {
6073
pub containers: HashMap<String, (String, bool)>,
6174
/// layer() calls: variable_name -> (layer name string, exported)
6275
pub layers: HashMap<String, (String, bool)>,
76+
/// createGlobalTheme() calls: variable_name -> GlobalThemeEntry
77+
pub global_themes: HashMap<String, GlobalThemeEntry>,
6378
/// Non-style constant exports: variable_name -> value (as code string)
6479
pub constant_exports: HashMap<String, String>,
6580
}
@@ -75,6 +90,7 @@ struct StyleCollectorInner {
7590
styles: CollectedStyles,
7691
style_counter: usize,
7792
font_counter: usize,
93+
global_theme_counter: usize,
7894
}
7995

8096
type StyleCollector = Rc<RefCell<StyleCollectorInner>>;
@@ -93,6 +109,13 @@ fn next_font_id(collector: &StyleCollector) -> String {
93109
id
94110
}
95111

112+
fn next_global_theme_id(collector: &StyleCollector) -> String {
113+
let mut inner = collector.borrow_mut();
114+
let id = format!("__global_theme_{}__", inner.global_theme_counter);
115+
inner.global_theme_counter += 1;
116+
id
117+
}
118+
96119
/// Parse font-face JSON and return list of (key, value) pairs
97120
/// Input: {"src":"local(...)","fontWeight":400}
98121
/// Output: vec![("src", "\"local(...)\""), ("fontWeight", "400")]
@@ -121,6 +144,80 @@ fn parse_font_face_json(json: &str) -> Vec<(String, String)> {
121144
.collect()
122145
}
123146

147+
/// Recursively transform theme object to CSS var() references
148+
/// Returns a new JS object with the same structure but leaf values replaced with var(--path)
149+
fn transform_theme_to_vars(
150+
value: &JsValue,
151+
ctx: &mut Context,
152+
placeholder_id: &str,
153+
css_vars: &mut Vec<(String, String)>,
154+
var_counter: &mut usize,
155+
path: &[String],
156+
) -> JsValue {
157+
if let Some(obj) = value.as_object() {
158+
// Check if it's an array (shouldn't happen in theme objects, but handle it)
159+
if obj.is_array() {
160+
return value.clone();
161+
}
162+
163+
// It's an object - recursively transform each property
164+
let new_obj = boa_engine::object::ObjectInitializer::new(ctx).build();
165+
166+
// Get own property keys
167+
if let Ok(keys) = obj.own_property_keys(ctx) {
168+
for key in keys {
169+
// Convert PropertyKey to string
170+
let key_string = match &key {
171+
boa_engine::property::PropertyKey::String(s) => s.to_std_string_escaped(),
172+
boa_engine::property::PropertyKey::Symbol(_) => continue,
173+
boa_engine::property::PropertyKey::Index(i) => i.get().to_string(),
174+
};
175+
176+
if let Ok(prop_value) = obj.get(js_string!(key_string.as_str()), ctx) {
177+
let mut new_path = path.to_vec();
178+
new_path.push(key_string.clone());
179+
180+
let transformed = transform_theme_to_vars(
181+
&prop_value,
182+
ctx,
183+
placeholder_id,
184+
css_vars,
185+
var_counter,
186+
&new_path,
187+
);
188+
189+
let _ = new_obj.set(js_string!(key_string.as_str()), transformed, false, ctx);
190+
}
191+
}
192+
}
193+
194+
JsValue::from(new_obj)
195+
} else {
196+
// Leaf value - create CSS variable
197+
let var_name = format!(
198+
"--{}-{}-{}",
199+
path.join("-"),
200+
placeholder_id.trim_matches('_').replace("__", "-"),
201+
var_counter
202+
);
203+
*var_counter += 1;
204+
205+
// Get the actual value as string
206+
let value_str = if let Some(s) = value.as_string() {
207+
s.to_std_string_escaped()
208+
} else if let Ok(s) = value.to_string(ctx) {
209+
s.to_std_string_escaped()
210+
} else {
211+
String::new()
212+
};
213+
214+
css_vars.push((var_name.clone(), value_str));
215+
216+
// Return var(--name)
217+
JsValue::from(js_string!(format!("var({})", var_name)))
218+
}
219+
}
220+
124221
/// Convert JsValue to JSON string using JSON.stringify
125222
fn js_value_to_json(value: &JsValue, context: &mut Context) -> String {
126223
// Use JSON.stringify to convert the value
@@ -271,6 +368,7 @@ fn is_style_api_call(expr: &oxc_ast::ast::Expression) -> bool {
271368
| "createVar"
272369
| "createContainer"
273370
| "layer"
371+
| "createGlobalTheme"
274372
);
275373
}
276374
false
@@ -293,8 +391,10 @@ fn remap_style_names(
293391
let mut new_containers = HashMap::new();
294392
let mut new_layers = HashMap::new();
295393
let mut new_font_faces = HashMap::new();
394+
let mut new_global_themes = HashMap::new();
296395
let mut style_idx = 0;
297396
let mut font_idx = 0;
397+
let mut global_theme_idx = 0;
298398

299399
// First pass: collect old entries preserving all fields
300400
let old_styles: HashMap<String, StyleEntry> = collected.styles.drain().collect();
@@ -318,6 +418,9 @@ fn remap_style_names(
318418
.drain()
319419
.map(|(k, v)| (k, (v.0, v.1)))
320420
.collect();
421+
// global_themes: placeholder_id -> GlobalThemeEntry (without exported flag for remapping)
422+
let old_global_themes: HashMap<String, GlobalThemeEntry> =
423+
collected.global_themes.drain().collect();
321424

322425
for (name, info) in vars {
323426
match info {
@@ -332,6 +435,22 @@ fn remap_style_names(
332435
continue;
333436
}
334437

438+
// Check if this is a createGlobalTheme (uses __global_theme_N__ placeholder)
439+
let global_theme_placeholder = format!("__global_theme_{}__", global_theme_idx);
440+
if let Some(entry) = old_global_themes.get(&global_theme_placeholder) {
441+
new_global_themes.insert(
442+
name.clone(),
443+
GlobalThemeEntry {
444+
selector: entry.selector.clone(),
445+
css_vars: entry.css_vars.clone(),
446+
vars_object_json: entry.vars_object_json.clone(),
447+
exported: *exported,
448+
},
449+
);
450+
global_theme_idx += 1;
451+
continue;
452+
}
453+
335454
let placeholder = format!("__style_{}__", style_idx);
336455
placeholder_to_name.insert(placeholder.clone(), name.clone());
337456

@@ -433,6 +552,7 @@ fn remap_style_names(
433552
collected.containers = new_containers;
434553
collected.layers = new_layers;
435554
collected.font_faces = new_font_faces;
555+
collected.global_themes = new_global_themes;
436556
}
437557

438558
/// Convert TypeScript to JavaScript using Oxc Transformer and replace imports
@@ -749,10 +869,48 @@ fn register_vanilla_extract_apis(
749869
};
750870

751871
// createGlobalTheme() function
872+
let collector_global_theme = collector.clone();
752873
let create_global_theme_fn = unsafe {
753-
NativeFunction::from_closure(move |_this, args, _ctx| {
874+
NativeFunction::from_closure(move |_this, args, ctx| {
875+
let placeholder_id = next_global_theme_id(&collector_global_theme);
876+
let selector = args
877+
.get_or_undefined(0)
878+
.to_string(ctx)
879+
.map(|s| s.to_std_string_escaped())
880+
.unwrap_or_else(|_| ":root".to_string());
754881
let theme_obj = args.get_or_undefined(1);
755-
Ok(theme_obj.clone())
882+
883+
// Collect CSS variables and build new object with var() references
884+
let mut css_vars = Vec::new();
885+
let mut var_counter = 0usize;
886+
let result_obj = transform_theme_to_vars(
887+
theme_obj,
888+
ctx,
889+
&placeholder_id,
890+
&mut css_vars,
891+
&mut var_counter,
892+
&[],
893+
);
894+
895+
// Serialize the result object to JSON for code generation
896+
let vars_object_json = js_value_to_json(&result_obj, ctx);
897+
898+
// Store the collected CSS variables and vars object
899+
collector_global_theme
900+
.borrow_mut()
901+
.styles
902+
.global_themes
903+
.insert(
904+
placeholder_id,
905+
GlobalThemeEntry {
906+
selector,
907+
css_vars,
908+
vars_object_json,
909+
exported: false,
910+
},
911+
);
912+
913+
Ok(result_obj)
756914
})
757915
};
758916

@@ -794,7 +952,10 @@ pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> S
794952
if !collected.styles.is_empty() || !collected.style_variants.is_empty() {
795953
imports.push("css");
796954
}
797-
if !collected.global_styles.is_empty() || !collected.font_faces.is_empty() {
955+
if !collected.global_styles.is_empty()
956+
|| !collected.font_faces.is_empty()
957+
|| !collected.global_themes.is_empty()
958+
{
798959
imports.push("globalCss");
799960
}
800961
if !collected.keyframes.is_empty() {
@@ -893,6 +1054,25 @@ pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> S
8931054
code_parts.push(code);
8941055
}
8951056

1057+
// Generate createGlobalTheme CSS variables via globalCss (sorted for deterministic output)
1058+
let mut global_themes_sorted: Vec<_> = collected.global_themes.iter().collect();
1059+
global_themes_sorted.sort_by_key(|(name, _)| *name);
1060+
for (_name, entry) in &global_themes_sorted {
1061+
if !entry.css_vars.is_empty() {
1062+
// Build CSS variables object for the selector
1063+
let vars_str = entry
1064+
.css_vars
1065+
.iter()
1066+
.map(|(var_name, value)| format!("\"{}\": \"{}\"", var_name, value))
1067+
.collect::<Vec<_>>()
1068+
.join(", ");
1069+
code_parts.push(format!(
1070+
"globalCss({{ \"{}\": {{ {} }} }})",
1071+
entry.selector, vars_str
1072+
));
1073+
}
1074+
}
1075+
8961076
// Generate keyframes declarations (sorted for deterministic output)
8971077
let mut keyframes: Vec<_> = collected.keyframes.iter().collect();
8981078
keyframes.sort_by_key(|(name, _)| *name);
@@ -991,6 +1171,15 @@ pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> S
9911171
code_parts.push(format!("{}const {} = \"{}\"", prefix, name, value));
9921172
}
9931173

1174+
// Generate createGlobalTheme vars object declarations (sorted for deterministic output)
1175+
for (name, entry) in &global_themes_sorted {
1176+
let prefix = if entry.exported { "export " } else { "" };
1177+
code_parts.push(format!(
1178+
"{}const {} = {}",
1179+
prefix, name, entry.vars_object_json
1180+
));
1181+
}
1182+
9941183
// Generate constant exports (sorted for deterministic output)
9951184
let mut constants: Vec<_> = collected.constant_exports.iter().collect();
9961185
constants.sort_by_key(|(name, _)| *name);
@@ -1012,6 +1201,7 @@ impl Clone for CollectedStyles {
10121201
style_variants: self.style_variants.clone(),
10131202
containers: self.containers.clone(),
10141203
layers: self.layers.clone(),
1204+
global_themes: self.global_themes.clone(),
10151205
constant_exports: self.constant_exports.clone(),
10161206
}
10171207
}

0 commit comments

Comments
 (0)