const TOKENS_JSON: &str = include_str!("tokens.json");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenEntry {
pub name: String,
pub value: String,
pub type_hint: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenGroup {
pub name: String,
pub description: Option<String>,
pub entries: Vec<TokenEntry>,
}
#[derive(Debug, Clone)]
pub struct Tokens {
pub groups: Vec<TokenGroup>,
}
pub fn load_tokens() -> Result<Tokens, String> {
parse_tokens(TOKENS_JSON)
}
pub fn parse_tokens(input: &str) -> Result<Tokens, String> {
let value: serde_json::Value = serde_json::from_str(input)
.map_err(|e| format!("tokens.json parse error: {}", e))?;
let top = value.as_object().ok_or("tokens.json must be a JSON object")?;
let order: Vec<String> = top
.get("$order")
.and_then(|v| v.as_array())
.ok_or("tokens.json missing top-level `$order` array")?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
let mut groups = Vec::with_capacity(order.len());
for group_name in &order {
let group_value = top
.get(group_name)
.ok_or_else(|| format!("`$order` lists '{}' but group is missing", group_name))?;
let group_obj = group_value
.as_object()
.ok_or_else(|| format!("group '{}' must be an object", group_name))?;
let mut description = None;
let mut entries = Vec::new();
for (entry_key, entry_value) in group_obj {
if entry_key == "$description" {
description = entry_value.as_str().map(|s| s.to_string());
continue;
}
if entry_key.starts_with('$') {
continue;
}
let entry_obj = entry_value
.as_object()
.ok_or_else(|| format!("entry '{}/{}' must be an object", group_name, entry_key))?;
let type_hint = entry_obj.get("$type").and_then(|v| v.as_str()).map(String::from);
let entry_value_str = entry_obj
.get("$value")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("entry '{}/{}' missing $value", group_name, entry_key))?
.to_string();
let entry_description = entry_obj.get("$description").and_then(|v| v.as_str()).map(String::from);
entries.push(TokenEntry {
name: entry_key.clone(),
value: entry_value_str,
type_hint,
description: entry_description,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
groups.push(TokenGroup {
name: group_name.clone(),
description,
entries,
});
}
Ok(Tokens { groups })
}
pub fn format_root_block(tokens: &Tokens) -> String {
let mut out = String::new();
out.push_str(":root {\n");
for (idx, group) in tokens.groups.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
let title = title_case(&group.name);
out.push_str(&format!(" /* {} */\n", title));
for entry in &group.entries {
let value = normalize_value(&entry.value, entry.type_hint.as_deref());
out.push_str(&format!(" --{}: {};\n", entry.name, value));
}
}
out.push_str("}\n");
out
}
fn title_case(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().chain(chars).collect(),
}
}
fn normalize_value(value: &str, type_hint: Option<&str>) -> String {
if matches!(type_hint, Some("color")) {
return normalize_hex_color(value);
}
value.to_string()
}
fn normalize_hex_color(value: &str) -> String {
let trimmed = value.trim();
if let Some(rest) = trimmed.strip_prefix('#') {
if rest.chars().all(|c| c.is_ascii_hexdigit())
&& (rest.len() == 3 || rest.len() == 6 || rest.len() == 8)
{
let lower = rest.to_lowercase();
if lower.len() == 3 {
let r = &lower[0..1];
let g = &lower[1..2];
let b = &lower[2..3];
return format!("#{r}{r}{g}{g}{b}{b}");
}
return format!("#{}", lower);
}
}
value.to_string()
}