Skip to main content

geoff_theme/
css.rs

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
9/// Generate CSS custom properties from a flat token map.
10/// If `dark_tokens` is provided, tokens differing between light and dark
11/// use the `light-dark()` function with `-on-light` / `-on-dark` primitives.
12///
13/// When `critical_only` is true, only tokens whose path contains "-critical" are included.
14pub 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
168/// Check if a string matches the pattern `light-dark({ref}, {ref})`.
169fn 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
180/// Expand `light-dark({ref1}, {ref2})` into CSS `light-dark(var(--name, fallback), var(--name, fallback))`.
181fn 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    // Strip trailing "._" (Style Dictionary root token convention)
337    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                // camelCase to kebab-case
344                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    // Handle camelCase properly
356    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}