Skip to main content

geoff_theme/
tokens.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5/// A parsed DTCG design token file. Groups and tokens are interleaved
6/// in the same tree — a node with `$value` is a token, without is a group.
7#[derive(Debug, Clone, Default)]
8pub struct DesignTokens {
9    pub entries: BTreeMap<String, TokenNode>,
10}
11
12/// A node in the token tree: either a group (contains children) or a token (has $value).
13#[derive(Debug, Clone)]
14pub enum TokenNode {
15    Token(Token),
16    Group(TokenGroup),
17}
18
19/// A single design token with a value and optional metadata.
20#[derive(Debug, Clone)]
21pub struct Token {
22    pub value: TokenValue,
23    pub token_type: Option<String>,
24    pub description: Option<String>,
25    pub extensions: Option<Value>,
26}
27
28/// A group of tokens or sub-groups, optionally with a shared type.
29#[derive(Debug, Clone)]
30pub struct TokenGroup {
31    pub group_type: Option<String>,
32    pub description: Option<String>,
33    pub children: BTreeMap<String, TokenNode>,
34}
35
36/// The value of a design token — can be a primitive, a reference, or a composite.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum TokenValue {
40    String(String),
41    Number(f64),
42    Bool(bool),
43    Array(Vec<Value>),
44    Object(CompositeValue),
45}
46
47/// A composite token value (dimension, typography, shadow, border, etc.).
48pub type CompositeValue = BTreeMap<String, Value>;
49
50fn parse_node(value: &Value) -> Option<TokenNode> {
51    let obj = value.as_object()?;
52
53    if obj.contains_key("$value") {
54        let token_value: TokenValue = serde_json::from_value(obj["$value"].clone()).ok()?;
55        Some(TokenNode::Token(Token {
56            value: token_value,
57            token_type: obj.get("$type").and_then(|v| v.as_str()).map(String::from),
58            description: obj
59                .get("$description")
60                .and_then(|v| v.as_str())
61                .map(String::from),
62            extensions: obj.get("$extensions").cloned(),
63        }))
64    } else {
65        let mut children = BTreeMap::new();
66        for (key, val) in obj {
67            if key.starts_with('$') {
68                continue;
69            }
70            if let Some(node) = parse_node(val) {
71                children.insert(key.clone(), node);
72            }
73        }
74        Some(TokenNode::Group(TokenGroup {
75            group_type: obj.get("$type").and_then(|v| v.as_str()).map(String::from),
76            description: obj
77                .get("$description")
78                .and_then(|v| v.as_str())
79                .map(String::from),
80            children,
81        }))
82    }
83}
84
85impl DesignTokens {
86    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
87        let raw: Value = serde_json::from_str(json)?;
88        let obj = raw.as_object().cloned().unwrap_or_default();
89
90        let mut entries = BTreeMap::new();
91        for (key, val) in &obj {
92            if key.starts_with('$') {
93                continue;
94            }
95            if let Some(node) = parse_node(val) {
96                entries.insert(key.clone(), node);
97            }
98        }
99
100        Ok(Self { entries })
101    }
102
103    pub fn from_file(path: &camino::Utf8Path) -> Result<Self, Box<dyn std::error::Error>> {
104        let content = std::fs::read_to_string(path)?;
105        Ok(Self::from_json(&content)?)
106    }
107
108    /// Flatten the token tree into a map of dot-separated paths to resolved tokens.
109    /// Group `$type` is inherited by children that don't specify their own.
110    pub fn flatten(&self) -> BTreeMap<String, FlatToken> {
111        let mut result = BTreeMap::new();
112        flatten_nodes(&self.entries, "", None, &mut result);
113        result
114    }
115}
116
117/// A flattened token with its full path and resolved type.
118#[derive(Debug, Clone)]
119pub struct FlatToken {
120    pub path: String,
121    pub value: TokenValue,
122    pub token_type: Option<String>,
123    pub description: Option<String>,
124}
125
126fn flatten_nodes(
127    entries: &BTreeMap<String, TokenNode>,
128    prefix: &str,
129    inherited_type: Option<&str>,
130    out: &mut BTreeMap<String, FlatToken>,
131) {
132    for (key, node) in entries {
133        if key.starts_with('$') {
134            continue;
135        }
136        let raw_path = if prefix.is_empty() {
137            key.clone()
138        } else {
139            format!("{prefix}.{key}")
140        };
141        // Strip trailing "._" (Style Dictionary root token convention where "_" represents the group-level value)
142        let path = if raw_path.ends_with("._") {
143            raw_path[..raw_path.len() - 2].to_string()
144        } else if key == "_" && !prefix.is_empty() {
145            prefix.to_string()
146        } else {
147            raw_path
148        };
149
150        match node {
151            TokenNode::Token(token) => {
152                let token_type = token
153                    .token_type
154                    .as_deref()
155                    .or(inherited_type)
156                    .map(|s| s.to_string());
157                out.insert(
158                    path.clone(),
159                    FlatToken {
160                        path,
161                        value: token.value.clone(),
162                        token_type,
163                        description: token.description.clone(),
164                    },
165                );
166            }
167            TokenNode::Group(group) => {
168                let group_type = group.group_type.as_deref().or(inherited_type);
169                flatten_nodes(&group.children, &path, group_type, out);
170            }
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn parse_simple_tokens() {
181        let json = r##"{
182            "color": {
183                "$type": "color",
184                "primary": { "$value": "#0066cc", "$description": "Brand color" },
185                "text": { "$value": "#1a1a1a" }
186            }
187        }"##;
188
189        let tokens = DesignTokens::from_json(json).unwrap();
190        let flat = tokens.flatten();
191
192        assert_eq!(flat.len(), 2);
193        assert!(flat.contains_key("color.primary"));
194        assert!(flat.contains_key("color.text"));
195        assert_eq!(flat["color.primary"].token_type.as_deref(), Some("color"));
196        assert_eq!(flat["color.text"].token_type.as_deref(), Some("color"));
197    }
198
199    #[test]
200    fn parse_composite_tokens() {
201        let json = r##"{
202            "spacing": {
203                "$type": "dimension",
204                "md": { "$value": { "value": 16, "unit": "px" } }
205            }
206        }"##;
207
208        let tokens = DesignTokens::from_json(json).unwrap();
209        let flat = tokens.flatten();
210
211        assert_eq!(flat.len(), 1);
212        assert_eq!(flat["spacing.md"].token_type.as_deref(), Some("dimension"));
213    }
214
215    #[test]
216    fn type_inheritance_from_group() {
217        let json = r##"{
218            "color": {
219                "$type": "color",
220                "primary": { "$value": "#0066cc" },
221                "secondary": { "$value": "#ff6b35", "$type": "color" }
222            }
223        }"##;
224
225        let tokens = DesignTokens::from_json(json).unwrap();
226        let flat = tokens.flatten();
227
228        assert_eq!(flat["color.primary"].token_type.as_deref(), Some("color"));
229        assert_eq!(flat["color.secondary"].token_type.as_deref(), Some("color"));
230    }
231
232    #[test]
233    fn nested_groups() {
234        let json = r##"{
235            "color": {
236                "$type": "color",
237                "neutral": {
238                    "100": { "$value": "#f5f5f5" },
239                    "900": { "$value": "#1a1a1a" }
240                }
241            }
242        }"##;
243
244        let tokens = DesignTokens::from_json(json).unwrap();
245        let flat = tokens.flatten();
246
247        assert!(flat.contains_key("color.neutral.100"));
248        assert!(flat.contains_key("color.neutral.900"));
249    }
250
251    #[test]
252    fn description_preserved() {
253        let json = r##"{
254            "color": {
255                "primary": { "$value": "#0066cc", "$description": "Main brand color" }
256            }
257        }"##;
258
259        let tokens = DesignTokens::from_json(json).unwrap();
260        let flat = tokens.flatten();
261
262        assert_eq!(
263            flat["color.primary"].description.as_deref(),
264            Some("Main brand color")
265        );
266    }
267
268    #[test]
269    fn tokens_with_extra_fields() {
270        let json = r##"{
271            "border": {
272                "width": {
273                    "$type": "dimension",
274                    "sm": {
275                        "$value": "1px",
276                        "$type": "dimension",
277                        "original": { "$value": "1px", "attributes": { "category": "border" } },
278                        "attributes": { "category": "border", "type": "width" },
279                        "name": "rh-border-width-sm",
280                        "path": ["border", "width", "sm"]
281                    }
282                }
283            }
284        }"##;
285
286        let tokens = DesignTokens::from_json(json).unwrap();
287        let flat = tokens.flatten();
288
289        assert!(flat.contains_key("border.width.sm"));
290        match &flat["border.width.sm"].value {
291            TokenValue::String(s) => assert_eq!(s, "1px"),
292            _ => panic!("expected string"),
293        }
294    }
295}