|
1 | | -//! Convert tweakcn CSS exports (OKLCH colors in shadcn token format) into |
2 | | -//! CastCodes `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear |
3 | | -//! sRGB formulas are short enough to vendor. |
| 1 | +//! Convert tweakcn CSS exports and shadcn registry JSON themes into CastCodes |
| 2 | +//! `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear sRGB formulas |
| 3 | +//! are short enough to vendor. |
| 4 | +
|
| 5 | +use std::collections::HashMap; |
4 | 6 |
|
5 | 7 | use pathfinder_color::ColorU; |
| 8 | +use serde_json::Value; |
6 | 9 |
|
7 | 10 | /// Convert OKLCH (L: 0..1, C: 0..0.4 typical, H: 0..360 degrees) to |
8 | 11 | /// 8-bit sRGB. Returns `Err((r, g, b))` if any channel was out of the |
@@ -63,11 +66,117 @@ pub enum ImportError { |
63 | 66 |
|
64 | 67 | #[derive(Debug, Default, PartialEq)] |
65 | 68 | pub struct ParsedBlocks { |
66 | | - pub light: std::collections::HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg) |
67 | | - pub dark: std::collections::HashMap<String, (f64, f64, f64)>, |
| 69 | + pub light: HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg) |
| 70 | + pub dark: HashMap<String, (f64, f64, f64)>, |
68 | 71 | pub name_comment: Option<String>, |
69 | 72 | } |
70 | 73 |
|
| 74 | +pub fn parse_blocks_or_json(input: &str) -> Result<ParsedBlocks, ImportError> { |
| 75 | + if input.trim_start().starts_with('{') { |
| 76 | + parse_registry_json(input) |
| 77 | + } else { |
| 78 | + parse_blocks(input) |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +pub fn parse_registry_json(input: &str) -> Result<ParsedBlocks, ImportError> { |
| 83 | + let value: Value = |
| 84 | + serde_json::from_str(input).map_err(|e| ImportError::Io(format!("invalid JSON: {e}")))?; |
| 85 | + let css_vars = value |
| 86 | + .get("cssVars") |
| 87 | + .and_then(Value::as_object) |
| 88 | + .ok_or(ImportError::NoColorBlocksFound)?; |
| 89 | + |
| 90 | + let mut blocks = ParsedBlocks { |
| 91 | + name_comment: value |
| 92 | + .get("name") |
| 93 | + .and_then(Value::as_str) |
| 94 | + .map(ToOwned::to_owned), |
| 95 | + ..Default::default() |
| 96 | + }; |
| 97 | + |
| 98 | + for (key, target) in [("light", &mut blocks.light), ("dark", &mut blocks.dark)] { |
| 99 | + let Some(vars) = css_vars.get(key).and_then(Value::as_object) else { |
| 100 | + continue; |
| 101 | + }; |
| 102 | + |
| 103 | + for (name, value) in vars { |
| 104 | + let Some(value) = value.as_str() else { |
| 105 | + continue; |
| 106 | + }; |
| 107 | + if let Some(triple) = parse_oklch_value(name, value)? { |
| 108 | + target.insert(name.clone(), triple); |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + if blocks.light.is_empty() && blocks.dark.is_empty() { |
| 114 | + return Err(ImportError::NoColorBlocksFound); |
| 115 | + } |
| 116 | + |
| 117 | + Ok(blocks) |
| 118 | +} |
| 119 | + |
| 120 | +fn parse_oklch_value(var: &str, value: &str) -> Result<Option<(f64, f64, f64)>, ImportError> { |
| 121 | + let Some(args) = value |
| 122 | + .trim() |
| 123 | + .strip_prefix("oklch(") |
| 124 | + .and_then(|s| s.strip_suffix(')')) |
| 125 | + else { |
| 126 | + return Ok(None); |
| 127 | + }; |
| 128 | + |
| 129 | + let triple: Vec<&str> = args.split_whitespace().take(3).collect(); |
| 130 | + if triple.len() < 3 { |
| 131 | + return Err(ImportError::InvalidOklch { |
| 132 | + var: var.to_string(), |
| 133 | + raw: value.to_string(), |
| 134 | + }); |
| 135 | + } |
| 136 | + |
| 137 | + let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN); |
| 138 | + // tweakcn emits L as 0..1 (no `%`), but tolerate `%` style: |
| 139 | + let l = if triple[0].ends_with('%') { |
| 140 | + l / 100.0 |
| 141 | + } else { |
| 142 | + l |
| 143 | + }; |
| 144 | + let c: f64 = triple[1].parse().unwrap_or(f64::NAN); |
| 145 | + let h: f64 = triple[2] |
| 146 | + .trim_end_matches("deg") |
| 147 | + .parse() |
| 148 | + .unwrap_or(f64::NAN); |
| 149 | + |
| 150 | + if l.is_finite() && c.is_finite() && h.is_finite() { |
| 151 | + Ok(Some((l, c, h))) |
| 152 | + } else { |
| 153 | + Err(ImportError::InvalidOklch { |
| 154 | + var: var.to_string(), |
| 155 | + raw: value.to_string(), |
| 156 | + }) |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +fn parse_css_decls( |
| 161 | + body: &str, |
| 162 | + target: &mut HashMap<String, (f64, f64, f64)>, |
| 163 | +) -> Result<(), ImportError> { |
| 164 | + for decl in body.split(';') { |
| 165 | + let decl = decl.trim(); |
| 166 | + if !decl.starts_with("--") { |
| 167 | + continue; |
| 168 | + } |
| 169 | + let Some((name, value)) = decl.split_once(':') else { |
| 170 | + continue; |
| 171 | + }; |
| 172 | + let name = name.trim().trim_start_matches("--").to_string(); |
| 173 | + if let Some(triple) = parse_oklch_value(&name, value)? { |
| 174 | + target.insert(name, triple); |
| 175 | + } |
| 176 | + } |
| 177 | + Ok(()) |
| 178 | +} |
| 179 | + |
71 | 180 | /// Pull `:root { ... }` and `.dark { ... }` blocks out of a tweakcn CSS |
72 | 181 | /// export. Parses each `--var: oklch(L C H);` line into a (L,C,H) triple. |
73 | 182 | /// `oklch()` is the only color function supported — anything else is |
@@ -132,62 +241,11 @@ pub fn parse_blocks(css: &str) -> Result<ParsedBlocks, ImportError> { |
132 | 241 | Some(&haystack[body_start..end]) |
133 | 242 | } |
134 | 243 |
|
135 | | - let parse_decls = |body: &str, |
136 | | - target: &mut std::collections::HashMap<String, (f64, f64, f64)>| |
137 | | - -> Result<(), ImportError> { |
138 | | - for decl in body.split(';') { |
139 | | - let decl = decl.trim(); |
140 | | - if !decl.starts_with("--") { |
141 | | - continue; |
142 | | - } |
143 | | - let Some((name, value)) = decl.split_once(':') else { |
144 | | - continue; |
145 | | - }; |
146 | | - let name = name.trim().trim_start_matches("--").to_string(); |
147 | | - let value = value.trim(); |
148 | | - // Only `oklch(L C H[ / a])` is supported; anything else is silently skipped. |
149 | | - let Some(args) = value |
150 | | - .strip_prefix("oklch(") |
151 | | - .and_then(|s| s.strip_suffix(')')) |
152 | | - else { |
153 | | - continue; |
154 | | - }; |
155 | | - let triple: Vec<&str> = args.split_whitespace().take(3).collect(); |
156 | | - if triple.len() < 3 { |
157 | | - return Err(ImportError::InvalidOklch { |
158 | | - var: name, |
159 | | - raw: value.to_string(), |
160 | | - }); |
161 | | - } |
162 | | - let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN); |
163 | | - // tweakcn emits L as 0..1 (no `%`), but tolerate `%` style: |
164 | | - let l = if triple[0].ends_with('%') { |
165 | | - l / 100.0 |
166 | | - } else { |
167 | | - l |
168 | | - }; |
169 | | - let c: f64 = triple[1].parse().unwrap_or(f64::NAN); |
170 | | - let h: f64 = triple[2] |
171 | | - .trim_end_matches("deg") |
172 | | - .parse() |
173 | | - .unwrap_or(f64::NAN); |
174 | | - if l.is_finite() && c.is_finite() && h.is_finite() { |
175 | | - target.insert(name, (l, c, h)); |
176 | | - } else { |
177 | | - return Err(ImportError::InvalidOklch { |
178 | | - var: name, |
179 | | - raw: value.to_string(), |
180 | | - }); |
181 | | - } |
182 | | - } |
183 | | - Ok(()) |
184 | | - }; |
185 | | - |
186 | 244 | if let Some(body) = extract_block(&cleaned, ":root") { |
187 | | - parse_decls(body, &mut blocks.light)?; |
| 245 | + parse_css_decls(body, &mut blocks.light)?; |
188 | 246 | } |
189 | 247 | if let Some(body) = extract_block(&cleaned, ".dark") { |
190 | | - parse_decls(body, &mut blocks.dark)?; |
| 248 | + parse_css_decls(body, &mut blocks.dark)?; |
191 | 249 | } |
192 | 250 |
|
193 | 251 | if blocks.light.is_empty() && blocks.dark.is_empty() { |
@@ -579,6 +637,53 @@ mod parse_block_tests { |
579 | 637 | assert!(blocks.light.is_empty()); |
580 | 638 | assert_eq!(blocks.dark.len(), 1); |
581 | 639 | } |
| 640 | + |
| 641 | + #[test] |
| 642 | + fn registry_json_extracts_light_and_dark_blocks() { |
| 643 | + let json = r##"{ |
| 644 | + "$schema": "https://ui.shadcn.com/schema/registry-item.json", |
| 645 | + "name": "vercel", |
| 646 | + "type": "registry:style", |
| 647 | + "cssVars": { |
| 648 | + "theme": { "font-sans": "Geist, sans-serif" }, |
| 649 | + "light": { |
| 650 | + "background": "oklch(0.99 0 0)", |
| 651 | + "foreground": "oklch(0 0 0)", |
| 652 | + "shadow-color": "#000000" |
| 653 | + }, |
| 654 | + "dark": { |
| 655 | + "background": "oklch(0 0 0)", |
| 656 | + "foreground": "oklch(1 0 0)", |
| 657 | + "border": "oklch(1 0 0 / 10%)" |
| 658 | + } |
| 659 | + } |
| 660 | +}"##; |
| 661 | + |
| 662 | + let blocks = parse_blocks_or_json(json).unwrap(); |
| 663 | + |
| 664 | + assert_eq!(blocks.name_comment.as_deref(), Some("vercel")); |
| 665 | + assert_eq!(blocks.light.len(), 2); |
| 666 | + assert_eq!(blocks.dark.len(), 3); |
| 667 | + assert_eq!(blocks.dark["foreground"], (1.0, 0.0, 0.0)); |
| 668 | + } |
| 669 | + |
| 670 | + #[test] |
| 671 | + fn registry_json_invalid_oklch_reports_the_variable() { |
| 672 | + let json = r#"{ |
| 673 | + "cssVars": { |
| 674 | + "light": { |
| 675 | + "background": "oklch(nope 0 0)" |
| 676 | + } |
| 677 | + } |
| 678 | +}"#; |
| 679 | + |
| 680 | + let result = parse_blocks_or_json(json); |
| 681 | + |
| 682 | + assert!(matches!( |
| 683 | + result, |
| 684 | + Err(ImportError::InvalidOklch { var, .. }) if var == "background" |
| 685 | + )); |
| 686 | + } |
582 | 687 | } |
583 | 688 |
|
584 | 689 | #[cfg(test)] |
|
0 commit comments