1use std::collections::BTreeMap;
2use std::fmt::Write;
3
4use serde_json::Value;
5
6use crate::resolve::is_reference;
7use crate::tokens::{FlatToken, TokenValue};
8
9pub fn generate_css(
15 tokens: &BTreeMap<String, FlatToken>,
16 dark_tokens: Option<&BTreeMap<String, FlatToken>>,
17 critical_only: bool,
18) -> String {
19 generate_css_with_prefix(tokens, dark_tokens, critical_only, None)
20}
21
22pub fn generate_css_with_prefix(
23 tokens: &BTreeMap<String, FlatToken>,
24 dark_tokens: Option<&BTreeMap<String, FlatToken>>,
25 critical_only: bool,
26 prefix: Option<&str>,
27) -> String {
28 let mut css = String::new();
29
30 if let Some(dark) = dark_tokens {
31 generate_light_dark(&mut css, tokens, dark, critical_only, prefix);
32 } else {
33 for (path, token) in tokens {
34 if critical_only && !path.contains("-critical") {
35 continue;
36 }
37 if !critical_only && path.contains("-critical") {
38 continue;
39 }
40 write_token_vars(
41 &mut css,
42 path,
43 &token.value,
44 token.token_type.as_deref(),
45 prefix,
46 Some(tokens),
47 );
48 }
49 }
50
51 css
52}
53
54fn generate_light_dark(
55 css: &mut String,
56 light: &BTreeMap<String, FlatToken>,
57 dark: &BTreeMap<String, FlatToken>,
58 critical_only: bool,
59 prefix: Option<&str>,
60) {
61 for (path, light_token) in light {
62 if critical_only && !path.contains("-critical") {
63 continue;
64 }
65 if !critical_only && path.contains("-critical") {
66 continue;
67 }
68
69 if let Some(dark_token) = dark.get(path) {
70 let light_val =
71 token_to_css_value(&light_token.value, light_token.token_type.as_deref());
72 let dark_val = token_to_css_value(&dark_token.value, dark_token.token_type.as_deref());
73
74 if light_val == dark_val {
75 write_token_vars(
76 css,
77 path,
78 &light_token.value,
79 light_token.token_type.as_deref(),
80 prefix,
81 Some(light),
82 );
83 } else {
84 let var_name = path_to_var_name_with_prefix(path, prefix);
85 let _ = writeln!(css, " {var_name}-on-light: {light_val};");
86 let _ = writeln!(css, " {var_name}-on-dark: {dark_val};");
87 let _ = writeln!(
88 css,
89 " {var_name}: light-dark(var({var_name}-on-light), var({var_name}-on-dark));"
90 );
91 }
92 } else {
93 write_token_vars(
94 css,
95 path,
96 &light_token.value,
97 light_token.token_type.as_deref(),
98 prefix,
99 Some(light),
100 );
101 }
102 }
103
104 for (path, dark_token) in dark {
105 if !light.contains_key(path) {
106 if critical_only && !path.contains("-critical") {
107 continue;
108 }
109 if !critical_only && path.contains("-critical") {
110 continue;
111 }
112 let var_name = path_to_var_name_with_prefix(path, prefix);
113 let val = token_to_css_value(&dark_token.value, dark_token.token_type.as_deref());
114 let _ = writeln!(css, " {var_name}-on-dark: {val};");
115 }
116 }
117}
118
119fn write_token_vars(
120 css: &mut String,
121 path: &str,
122 value: &TokenValue,
123 token_type: Option<&str>,
124 prefix: Option<&str>,
125 all_tokens: Option<&BTreeMap<String, FlatToken>>,
126) {
127 match (value, token_type) {
128 (TokenValue::Object(obj), Some("typography")) => {
129 write_composite_vars(css, path, obj, prefix);
130 }
131 (TokenValue::Object(obj), Some("shadow")) => {
132 let shorthand = shadow_to_shorthand(obj);
133 let var_name = path_to_var_name_with_prefix(path, prefix);
134 let _ = writeln!(css, " {var_name}: {shorthand};");
135 }
136 (TokenValue::Object(obj), Some("border")) => {
137 let shorthand = border_to_shorthand(obj);
138 let var_name = path_to_var_name_with_prefix(path, prefix);
139 let _ = writeln!(css, " {var_name}: {shorthand};");
140 }
141 (TokenValue::Array(arr), Some("shadow")) => {
142 let parts: Vec<String> = arr
143 .iter()
144 .filter_map(|v| {
145 v.as_object().map(|o| {
146 let map: BTreeMap<String, Value> =
147 o.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
148 shadow_to_shorthand(&map)
149 })
150 })
151 .collect();
152 let var_name = path_to_var_name_with_prefix(path, prefix);
153 let _ = writeln!(css, " {var_name}: {};", parts.join(", "));
154 }
155 (TokenValue::String(s), _) if is_light_dark_refs(s) => {
156 let var_name = path_to_var_name_with_prefix(path, prefix);
157 let val = expand_light_dark(s, prefix, all_tokens);
158 let _ = writeln!(css, " {var_name}: {val};");
159 }
160 _ => {
161 let var_name = path_to_var_name_with_prefix(path, prefix);
162 let val = token_to_css_value(value, token_type);
163 let _ = writeln!(css, " {var_name}: {val};");
164 }
165 }
166}
167
168fn is_light_dark_refs(s: &str) -> bool {
170 if let Some(inner) = s
171 .strip_prefix("light-dark(")
172 .and_then(|s| s.strip_suffix(')'))
173 && let Some((light, dark)) = inner.split_once(", ")
174 {
175 return is_reference(light) && is_reference(dark);
176 }
177 false
178}
179
180fn expand_light_dark(
182 s: &str,
183 prefix: Option<&str>,
184 all_tokens: Option<&BTreeMap<String, FlatToken>>,
185) -> String {
186 let inner = s
187 .strip_prefix("light-dark(")
188 .and_then(|s| s.strip_suffix(')'))
189 .unwrap();
190 let (light_ref, dark_ref) = inner.split_once(", ").unwrap();
191 let light_path = &light_ref[1..light_ref.len() - 1];
192 let dark_path = &dark_ref[1..dark_ref.len() - 1];
193 let light_var = path_to_var_name_with_prefix(light_path, prefix);
194 let dark_var = path_to_var_name_with_prefix(dark_path, prefix);
195
196 let light_fallback = all_tokens
197 .and_then(|t| t.get(light_path))
198 .map(|t| token_to_css_value(&t.value, t.token_type.as_deref()));
199 let dark_fallback = all_tokens
200 .and_then(|t| t.get(dark_path))
201 .map(|t| token_to_css_value(&t.value, t.token_type.as_deref()));
202
203 match (light_fallback, dark_fallback) {
204 (Some(lf), Some(df)) => {
205 format!("light-dark(var({light_var}, {lf}), var({dark_var}, {df}))")
206 }
207 _ => format!("light-dark(var({light_var}), var({dark_var}))"),
208 }
209}
210
211fn write_composite_vars(
212 css: &mut String,
213 path: &str,
214 obj: &BTreeMap<String, Value>,
215 prefix: Option<&str>,
216) {
217 for (key, val) in obj {
218 let sub_path = format!("{path}.{key}");
219 let var_name = path_to_var_name_with_prefix(&sub_path, prefix);
220 let css_val = json_to_css_value(val);
221 let _ = writeln!(css, " {var_name}: {css_val};");
222 }
223}
224
225fn shadow_to_shorthand(obj: &BTreeMap<String, Value>) -> String {
226 let offset_x = dimension_val(obj.get("offsetX"));
227 let offset_y = dimension_val(obj.get("offsetY"));
228 let blur = dimension_val(obj.get("blur"));
229 let spread = dimension_val(obj.get("spread"));
230 let color = obj
231 .get("color")
232 .and_then(|v| v.as_str())
233 .unwrap_or("transparent");
234 format!("{offset_x} {offset_y} {blur} {spread} {color}")
235}
236
237fn border_to_shorthand(obj: &BTreeMap<String, Value>) -> String {
238 let width = dimension_val(obj.get("width"));
239 let style = obj.get("style").and_then(|v| v.as_str()).unwrap_or("solid");
240 let color = obj
241 .get("color")
242 .and_then(|v| v.as_str())
243 .unwrap_or("currentColor");
244 format!("{width} {style} {color}")
245}
246
247fn dimension_val(val: Option<&Value>) -> String {
248 match val {
249 Some(Value::Object(obj)) => {
250 let v = obj.get("value").and_then(|n| n.as_f64()).unwrap_or(0.0);
251 let unit = obj.get("unit").and_then(|u| u.as_str()).unwrap_or("px");
252 if v == 0.0 {
253 "0".to_string()
254 } else if v.fract() == 0.0 {
255 format!("{}{unit}", v as i64)
256 } else {
257 format!("{v}{unit}")
258 }
259 }
260 Some(Value::Number(n)) => {
261 let v = n.as_f64().unwrap_or(0.0);
262 if v == 0.0 {
263 "0".to_string()
264 } else {
265 format!("{v}px")
266 }
267 }
268 _ => "0".to_string(),
269 }
270}
271
272fn token_to_css_value(value: &TokenValue, token_type: Option<&str>) -> String {
273 match value {
274 TokenValue::String(s) => s.clone(),
275 TokenValue::Number(n) => {
276 if n.fract() == 0.0 {
277 format!("{}", *n as i64)
278 } else {
279 format!("{n}")
280 }
281 }
282 TokenValue::Bool(b) => b.to_string(),
283 TokenValue::Object(obj) => match token_type {
284 Some("dimension") => dimension_val(Some(&Value::Object(
285 obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
286 ))),
287 Some("duration") => {
288 let v = obj.get("value").and_then(|n| n.as_f64()).unwrap_or(0.0);
289 let unit = obj.get("unit").and_then(|u| u.as_str()).unwrap_or("ms");
290 format!("{v}{unit}")
291 }
292 Some("shadow") => shadow_to_shorthand(obj),
293 Some("border") => border_to_shorthand(obj),
294 _ => serde_json::to_string(obj).unwrap_or_default(),
295 },
296 TokenValue::Array(arr) => match token_type {
297 Some("fontFamily") => arr
298 .iter()
299 .map(|v| v.as_str().unwrap_or("").to_string())
300 .collect::<Vec<_>>()
301 .join(", "),
302 Some("cubicBezier") => {
303 let nums: Vec<String> = arr
304 .iter()
305 .filter_map(|v| v.as_f64().map(|n| format!("{n}")))
306 .collect();
307 format!("cubic-bezier({})", nums.join(", "))
308 }
309 _ => serde_json::to_string(arr).unwrap_or_default(),
310 },
311 }
312}
313
314fn json_to_css_value(val: &Value) -> String {
315 match val {
316 Value::String(s) => s.clone(),
317 Value::Number(n) => {
318 if let Some(i) = n.as_i64() {
319 format!("{i}")
320 } else {
321 format!("{}", n.as_f64().unwrap_or(0.0))
322 }
323 }
324 Value::Object(obj) => dimension_val(Some(&Value::Object(obj.clone()))),
325 Value::Array(arr) => arr
326 .iter()
327 .map(json_to_css_value)
328 .collect::<Vec<_>>()
329 .join(", "),
330 Value::Bool(b) => b.to_string(),
331 Value::Null => "none".to_string(),
332 }
333}
334
335fn path_to_var_name_with_prefix(raw_path: &str, prefix: Option<&str>) -> String {
336 let path = raw_path.strip_suffix("._").unwrap_or(raw_path);
338 let kebab = path
339 .chars()
340 .map(|c| match c {
341 '.' => '-',
342 c if c.is_uppercase() => {
343 format!("-{}", c.to_lowercase())
345 .chars()
346 .collect::<String>()
347 .chars()
348 .next()
349 .unwrap_or(c)
350 }
351 _ => c,
352 })
353 .collect::<String>();
354
355 let mut result = String::with_capacity(kebab.len() + 10);
357 result.push_str("--");
358 if let Some(pfx) = prefix {
359 result.push_str(pfx);
360 result.push('-');
361 }
362 let chars: Vec<char> = path.chars().collect();
363 for (i, &c) in chars.iter().enumerate() {
364 if c == '.' {
365 result.push('-');
366 } else if c.is_uppercase() && i > 0 && chars[i - 1] != '.' {
367 result.push('-');
368 result.push(c.to_ascii_lowercase());
369 } else {
370 result.push(c.to_ascii_lowercase());
371 }
372 }
373 result
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::tokens::DesignTokens;
380
381 #[test]
382 fn simple_color_tokens() {
383 let json = r##"{
384 "color": {
385 "$type": "color",
386 "primary": { "$value": "#0066cc" },
387 "text": { "$value": "#1a1a1a" }
388 }
389 }"##;
390
391 let tokens = DesignTokens::from_json(json).unwrap();
392 let flat = tokens.flatten();
393 let css = generate_css(&flat, None, false);
394
395 assert!(css.contains("--color-primary: #0066cc;"));
396 assert!(css.contains("--color-text: #1a1a1a;"));
397 }
398
399 #[test]
400 fn dimension_tokens() {
401 let json = r##"{
402 "spacing": {
403 "$type": "dimension",
404 "md": { "$value": { "value": 16, "unit": "px" } },
405 "lg": { "$value": { "value": 1.5, "unit": "rem" } }
406 }
407 }"##;
408
409 let tokens = DesignTokens::from_json(json).unwrap();
410 let flat = tokens.flatten();
411 let css = generate_css(&flat, None, false);
412
413 assert!(css.contains("--spacing-md: 16px;"));
414 assert!(css.contains("--spacing-lg: 1.5rem;"));
415 }
416
417 #[test]
418 fn typography_composite_expands() {
419 let json = r##"{
420 "typography": {
421 "body": {
422 "$type": "typography",
423 "$value": {
424 "fontFamily": "system-ui",
425 "fontSize": { "value": 16, "unit": "px" },
426 "fontWeight": 400,
427 "lineHeight": 1.5
428 }
429 }
430 }
431 }"##;
432
433 let tokens = DesignTokens::from_json(json).unwrap();
434 let flat = tokens.flatten();
435 let css = generate_css(&flat, None, false);
436
437 assert!(css.contains("--typography-body-font-family: system-ui;"));
438 assert!(css.contains("--typography-body-font-size: 16px;"));
439 assert!(css.contains("--typography-body-font-weight: 400;"));
440 assert!(css.contains("--typography-body-line-height: 1.5;"));
441 }
442
443 #[test]
444 fn shadow_shorthand() {
445 let json = r##"{
446 "shadow": {
447 "default": {
448 "$type": "shadow",
449 "$value": {
450 "color": "#00000014",
451 "offsetX": { "value": 0, "unit": "px" },
452 "offsetY": { "value": 2, "unit": "px" },
453 "blur": { "value": 4, "unit": "px" },
454 "spread": { "value": 0, "unit": "px" }
455 }
456 }
457 }
458 }"##;
459
460 let tokens = DesignTokens::from_json(json).unwrap();
461 let flat = tokens.flatten();
462 let css = generate_css(&flat, None, false);
463
464 assert!(css.contains("--shadow-default: 0 2px 4px 0 #00000014;"));
465 }
466
467 #[test]
468 fn light_dark_mode() {
469 let light_json = r##"{
470 "color": {
471 "$type": "color",
472 "bg": { "$value": "#ffffff" },
473 "text": { "$value": "#1a1a1a" }
474 },
475 "spacing": {
476 "$type": "dimension",
477 "md": { "$value": { "value": 16, "unit": "px" } }
478 }
479 }"##;
480
481 let dark_json = r##"{
482 "color": {
483 "$type": "color",
484 "bg": { "$value": "#1a1a1a" },
485 "text": { "$value": "#f5f5f5" }
486 }
487 }"##;
488
489 let light = DesignTokens::from_json(light_json).unwrap().flatten();
490 let dark = DesignTokens::from_json(dark_json).unwrap().flatten();
491 let css = generate_css(&light, Some(&dark), false);
492
493 assert!(css.contains("--color-bg-on-light: #ffffff;"));
494 assert!(css.contains("--color-bg-on-dark: #1a1a1a;"));
495 assert!(css.contains(
496 "--color-bg: light-dark(var(--color-bg-on-light), var(--color-bg-on-dark));"
497 ));
498 assert!(css.contains("--spacing-md: 16px;"));
499 assert!(!css.contains("--spacing-md-on-light"));
500 }
501
502 #[test]
503 fn critical_filter() {
504 let json = r##"{
505 "color-critical": {
506 "$type": "color",
507 "bg": { "$value": "#ffffff" }
508 },
509 "color": {
510 "$type": "color",
511 "accent": { "$value": "#ff6b35" }
512 }
513 }"##;
514
515 let tokens = DesignTokens::from_json(json).unwrap();
516 let flat = tokens.flatten();
517
518 let critical = generate_css(&flat, None, true);
519 let deferred = generate_css(&flat, None, false);
520
521 assert!(critical.contains("--color-critical-bg: #ffffff;"));
522 assert!(!critical.contains("--color-accent"));
523
524 assert!(deferred.contains("--color-accent: #ff6b35;"));
525 assert!(!deferred.contains("--color-critical-bg"));
526 }
527
528 #[test]
529 fn camel_case_to_kebab() {
530 assert_eq!(
531 path_to_var_name_with_prefix("typography.body.fontSize", None),
532 "--typography-body-font-size"
533 );
534 assert_eq!(
535 path_to_var_name_with_prefix("color.primary", None),
536 "--color-primary"
537 );
538 assert_eq!(
539 path_to_var_name_with_prefix("spacing.md", None),
540 "--spacing-md"
541 );
542 }
543}