//! Tokens → Tailwind config (TypeScript) + CSS custom-property emission. //! Emits stable, deterministically-ordered output (BTreeMap input guarantees). use crate::parse::Tokens; use std::fmt::Write as _; use std::fs; use std::path::Path; pub fn tailwind_config(t: &Tokens, out: &Path) -> Result<(), String> { let mut s = String::new(); s.push_str("// GENERATED by tokens-sync — do not edit by hand.\n"); s.push_str("import type { Config } from 'tailwindcss';\n\n"); s.push_str("const config: Config = {\n"); s.push_str(" content: [],\n"); s.push_str(" theme: {\n extend: {\n"); emit_category(&mut s, "colors", &t.colors); emit_category(&mut s, "fontFamily", &t.fonts); emit_category(&mut s, "spacing", &t.spacing); emit_category(&mut s, "borderRadius", &t.radius); s.push_str(" },\n },\n plugins: [],\n};\n\nexport default config;\n"); fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display())) } pub fn css_vars(t: &Tokens, out: &Path) -> Result<(), String> { let mut s = String::new(); s.push_str("/* GENERATED by tokens-sync — do not edit by hand. */\n:root {\n"); for (k, v) in &t.colors { let _ = writeln!(s, " --color-{}: {};", css_ident(k), v); } for (k, v) in &t.fonts { let _ = writeln!(s, " --font-{}: {};", css_ident(k), v); } for (k, v) in &t.spacing { let _ = writeln!(s, " --space-{}: {};", css_ident(k), v); } for (k, v) in &t.radius { let _ = writeln!(s, " --radius-{}: {};", css_ident(k), v); } s.push_str("}\n"); fs::write(out, s).map_err(|e| format!("write {}: {e}", out.display())) } fn emit_category( s: &mut String, key: &str, map: &std::collections::BTreeMap, ) { if map.is_empty() { return; } let _ = writeln!(s, " {key}: {{"); for (k, v) in map { // Escape JS single quotes inside the value string. let escaped = v.replace('\'', "\\'"); let _ = writeln!(s, " '{k}': '{escaped}',"); } s.push_str(" },\n"); } fn css_ident(raw: &str) -> String { raw.chars() .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }) .collect() } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; fn sample_tokens() -> Tokens { let mut colors = BTreeMap::new(); colors.insert("primary".into(), "oklch(0.6 0.2 250)".into()); colors.insert("surface".into(), "oklch(0.99 0 0)".into()); let mut fonts = BTreeMap::new(); fonts.insert("body".into(), "Inter, sans-serif".into()); let mut spacing = BTreeMap::new(); spacing.insert("md".into(), "1rem".into()); let mut radius = BTreeMap::new(); radius.insert("card".into(), "0.75rem".into()); Tokens { colors, fonts, spacing, radius } } #[test] fn tailwind_emits_extend_categories() { let dir = tempfile::tempdir().unwrap(); let out = dir.path().join("tw.ts"); tailwind_config(&sample_tokens(), &out).unwrap(); let text = fs::read_to_string(&out).unwrap(); assert!(text.contains("colors: {")); assert!(text.contains("'primary': 'oklch(0.6 0.2 250)'")); assert!(text.contains("fontFamily: {")); assert!(text.contains("borderRadius: {")); } #[test] fn css_emits_vars_under_root() { let dir = tempfile::tempdir().unwrap(); let out = dir.path().join("tokens.css"); css_vars(&sample_tokens(), &out).unwrap(); let text = fs::read_to_string(&out).unwrap(); assert!(text.contains(":root {")); assert!(text.contains("--color-primary: oklch(0.6 0.2 250);")); assert!(text.contains("--font-body: Inter, sans-serif;")); assert!(text.contains("--space-md: 1rem;")); assert!(text.contains("--radius-card: 0.75rem;")); } #[test] fn empty_tokens_still_emit_valid_shells() { let dir = tempfile::tempdir().unwrap(); let t = Tokens::default(); let tw = dir.path().join("tw.ts"); let css = dir.path().join("c.css"); tailwind_config(&t, &tw).unwrap(); css_vars(&t, &css).unwrap(); let css_text = fs::read_to_string(&css).unwrap(); assert!(css_text.contains(":root {")); assert!(css_text.trim_end().ends_with('}')); } }