use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use anyhow::{Result, bail};
pub(crate) fn ingest(input: &Path, out: &Path) -> Result<()> {
let source = fs::read_to_string(input)?;
let base_tokens = extract_define_tokens(&source)?;
let semantic = extract_semantic_tokens(&source)?;
let mut palette: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in CHAKRA_DEFAULTS {
palette.insert(k.to_string(), v.to_string());
}
if let Some(JsValue::Obj(colors_obj)) = base_tokens.get("colors") {
flatten_colors(colors_obj, "", &mut palette);
}
for _ in 0..4 {
let snapshot = palette.clone();
let mut changed = false;
for val in palette.values_mut() {
if val.starts_with('{')
&& val.ends_with('}')
&& let Some(resolved) = resolve_ref(val, &snapshot)
{
*val = resolved;
changed = true;
}
}
if !changed {
break;
}
}
let sem_colors = match semantic.get("colors") {
Some(JsValue::Obj(obj)) => obj.clone(),
_ => BTreeMap::new(),
};
let mut default_refs: BTreeMap<String, String> = BTreeMap::new();
let mut dark_refs: BTreeMap<String, String> = BTreeMap::new();
collect_semantic_refs(&sem_colors, "", &mut default_refs, &mut dark_refs);
let default_colors = resolve_refs_map(&default_refs, &palette);
let dark_pass1 = resolve_refs_map(&dark_refs, &palette);
let mut dark_palette = palette.clone();
dark_palette.extend(dark_pass1);
let dark_colors = resolve_refs_map(&dark_refs, &dark_palette);
let mut all_colors: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in &palette {
if !CHAKRA_DEFAULTS.iter().any(|(dk, _)| *dk == k.as_str()) {
all_colors.insert(k.clone(), v.clone());
}
}
for (k, v) in &default_colors {
all_colors.insert(k.clone(), v.clone());
}
if !all_colors.contains_key("primary")
&& let Some(v) = default_colors
.get("brand.solid")
.or(palette.get("brand.500"))
{
all_colors.insert("primary".into(), v.clone());
}
if !all_colors.contains_key("background") {
all_colors.insert(
"background".into(),
palette.get("gray.900").cloned().unwrap_or("#171923".into()),
);
}
if !all_colors.contains_key("text")
&& let Some(v) = default_colors.get("text")
{
all_colors.insert("text".into(), v.clone());
}
let mut yaml =
String::from("# Auto-generated from Chakra theme by rlvgl-creator\nversion: 1\n");
yaml.push_str("colors:\n");
for (k, v) in &all_colors {
let key = sanitize_key(k);
yaml.push_str(&format!(" {}: \"{}\"\n", key, v));
}
yaml.push_str("spacing:\n xs: 2\n sm: 4\n md: 8\n lg: 16\n xl: 24\n");
yaml.push_str("radii:\n none: 0\n sm: 2\n md: 4\n lg: 8\n full: 255\n");
yaml.push_str("fonts:\n small: \"tiny\"\n body: \"default\"\n heading: \"bold\"\n");
if !dark_colors.is_empty() {
yaml.push_str("modes:\n dark:\n colors:\n");
for (k, v) in &dark_colors {
let key = sanitize_key(k);
yaml.push_str(&format!(" {}: \"{}\"\n", key, v));
}
}
fs::create_dir_all(out)?;
let out_path = out.join("tokens.yaml");
fs::write(&out_path, &yaml)?;
println!("Generated {}", out_path.display());
Ok(())
}
type JsObj = BTreeMap<String, JsValue>;
#[derive(Debug, Clone)]
enum JsValue {
Str(String),
Obj(JsObj),
}
fn extract_define_tokens(source: &str) -> Result<JsObj> {
extract_call_arg(source, "defineTokens")
}
fn extract_semantic_tokens(source: &str) -> Result<JsObj> {
extract_call_arg(source, "defineSemanticTokens")
}
fn extract_call_arg(source: &str, fn_name: &str) -> Result<JsObj> {
let pattern = format!("{}({{", fn_name);
let start = match source.find(&pattern) {
Some(pos) => pos + fn_name.len() + 1, None => bail!("could not find `{}({{` in source", fn_name),
};
let slice = &source[start..];
let (obj, _) = parse_js_obj(slice)?;
Ok(obj)
}
fn parse_js_obj(s: &str) -> Result<(JsObj, usize)> {
let s = s.trim_start();
if !s.starts_with('{') {
bail!("expected '{{' at start of JS object");
}
let mut map = BTreeMap::new();
let mut pos = 1;
loop {
let rest = &s[pos..];
let rest = rest.trim_start();
pos = s.len() - rest.len();
if rest.starts_with('}') {
pos += 1;
break;
}
if rest.is_empty() {
bail!("unexpected end of input in JS object");
}
if rest.starts_with(',') {
pos += 1;
continue;
}
if rest.starts_with("//") {
if let Some(nl) = rest.find('\n') {
pos += nl + 1;
} else {
pos += rest.len();
}
continue;
}
let (key, consumed) = parse_js_key(rest)?;
pos += consumed;
let rest = &s[pos..];
let rest = rest.trim_start();
pos = s.len() - rest.len();
if !rest.starts_with(':') {
bail!("expected ':' after key '{}'", key);
}
pos += 1;
let rest = &s[pos..];
let rest = rest.trim_start();
pos = s.len() - rest.len();
if rest.starts_with('{') {
let (obj, consumed) = parse_js_obj(rest)?;
pos += consumed;
map.insert(key, JsValue::Obj(obj));
} else if rest.starts_with('"') || rest.starts_with('\'') {
let (val, consumed) = parse_js_string(rest)?;
pos += consumed;
map.insert(key, JsValue::Str(val));
} else {
let end = rest.find([',', '}', '\n']).unwrap_or(rest.len());
let raw = rest[..end].trim();
pos += end;
map.insert(key, JsValue::Str(raw.to_string()));
}
}
Ok((map, pos))
}
fn parse_js_key(s: &str) -> Result<(String, usize)> {
if s.starts_with('"') || s.starts_with('\'') {
parse_js_string(s)
} else {
let end = s
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$')
.unwrap_or(s.len());
if end == 0 {
bail!("empty unquoted key");
}
Ok((s[..end].to_string(), end))
}
}
fn parse_js_string(s: &str) -> Result<(String, usize)> {
let quote = s.as_bytes()[0] as char;
let mut pos = 1;
let mut val = String::new();
while pos < s.len() {
let ch = s.as_bytes()[pos] as char;
if ch == '\\' && pos + 1 < s.len() {
val.push(s.as_bytes()[pos + 1] as char);
pos += 2;
} else if ch == quote {
pos += 1;
return Ok((val, pos));
} else {
val.push(ch);
pos += 1;
}
}
bail!("unterminated string");
}
fn flatten_colors(obj: &JsObj, prefix: &str, out: &mut BTreeMap<String, String>) {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match val {
JsValue::Str(s) => {
out.insert(path, s.clone());
}
JsValue::Obj(inner) => {
if let Some(JsValue::Str(v)) = inner.get("value") {
out.insert(path, v.clone());
} else {
flatten_colors(inner, &path, out);
}
}
}
}
}
fn resolve_ref(reference: &str, palette: &BTreeMap<String, String>) -> Option<String> {
let inner = reference.trim_start_matches('{').trim_end_matches('}');
let key = inner.strip_prefix("colors.").unwrap_or(inner);
if let Some(val) = palette.get(key) {
if val.starts_with('{') && val.ends_with('}') {
return resolve_ref(val, palette);
}
return Some(val.clone());
}
None
}
fn collect_semantic_refs(
obj: &JsObj,
prefix: &str,
default_out: &mut BTreeMap<String, String>,
dark_out: &mut BTreeMap<String, String>,
) {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match val {
JsValue::Obj(inner) => {
let has_default = inner.contains_key("default");
let has_dark = inner.contains_key("_dark");
if has_default || has_dark {
if let Some(JsValue::Obj(def)) = inner.get("default")
&& let Some(JsValue::Str(v)) = def.get("value")
{
default_out.insert(path.clone(), v.clone());
}
if let Some(JsValue::Obj(dark)) = inner.get("_dark")
&& let Some(JsValue::Str(v)) = dark.get("value")
{
dark_out.insert(path.clone(), v.clone());
}
} else {
collect_semantic_refs(inner, &path, default_out, dark_out);
}
}
JsValue::Str(_) => {}
}
}
}
fn resolve_refs_map(
refs: &BTreeMap<String, String>,
palette: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
refs.iter()
.map(|(k, v)| {
let resolved = resolve_ref(v, palette).unwrap_or_else(|| v.clone());
(k.clone(), resolved)
})
.collect()
}
fn sanitize_key(key: &str) -> String {
key.replace('.', "-")
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
const CHAKRA_DEFAULTS: &[(&str, &str)] = &[
("white", "#ffffff"),
("black", "#000000"),
("gray.50", "#f7fafc"),
("gray.100", "#edf2f7"),
("gray.200", "#e2e8f0"),
("gray.300", "#cbd5e0"),
("gray.400", "#a0aec0"),
("gray.500", "#718096"),
("gray.600", "#4a5568"),
("gray.700", "#2d3748"),
("gray.800", "#1a202c"),
("gray.900", "#171923"),
("red.50", "#fff5f5"),
("red.100", "#fed7d7"),
("red.200", "#feb2b2"),
("red.300", "#fc8181"),
("red.400", "#f56565"),
("red.500", "#e53e3e"),
("red.600", "#c53030"),
("red.700", "#9b2c2c"),
("red.800", "#822727"),
("red.900", "#63171b"),
("orange.50", "#fffaf0"),
("orange.100", "#feebc8"),
("orange.200", "#fbd38d"),
("orange.300", "#f6ad55"),
("orange.400", "#ed8936"),
("orange.500", "#dd6b20"),
("orange.600", "#c05621"),
("orange.700", "#9c4221"),
("orange.800", "#7b341e"),
("orange.900", "#652b19"),
("yellow.50", "#fffff0"),
("yellow.100", "#fefcbf"),
("yellow.200", "#faf089"),
("yellow.300", "#f6e05e"),
("yellow.400", "#ecc94b"),
("yellow.500", "#d69e2e"),
("yellow.600", "#b7791f"),
("yellow.700", "#975a16"),
("yellow.800", "#744210"),
("yellow.900", "#5f370e"),
("green.50", "#f0fff4"),
("green.100", "#c6f6d5"),
("green.200", "#9ae6b4"),
("green.300", "#68d391"),
("green.400", "#48bb78"),
("green.500", "#38a169"),
("green.600", "#2f855a"),
("green.700", "#276749"),
("green.800", "#22543d"),
("green.900", "#1c4532"),
("teal.50", "#e6fffa"),
("teal.100", "#b2f5ea"),
("teal.200", "#81e6d9"),
("teal.300", "#4fd1c5"),
("teal.400", "#38b2ac"),
("teal.500", "#319795"),
("teal.600", "#2c7a7b"),
("teal.700", "#285e61"),
("teal.800", "#234e52"),
("teal.900", "#1d4044"),
("blue.50", "#ebf8ff"),
("blue.100", "#bee3f8"),
("blue.200", "#90cdf4"),
("blue.300", "#63b3ed"),
("blue.400", "#4299e1"),
("blue.500", "#3182ce"),
("blue.600", "#2b6cb0"),
("blue.700", "#2c5282"),
("blue.800", "#2a4365"),
("blue.900", "#1a365d"),
("cyan.50", "#edfdfd"),
("cyan.100", "#c4f1f9"),
("cyan.200", "#9decf9"),
("cyan.300", "#76e4f7"),
("cyan.400", "#0bc5ea"),
("cyan.500", "#00b5d8"),
("cyan.600", "#00a3c4"),
("cyan.700", "#0987a0"),
("cyan.800", "#086f83"),
("cyan.900", "#065666"),
("purple.50", "#faf5ff"),
("purple.100", "#e9d8fd"),
("purple.200", "#d6bcfa"),
("purple.300", "#b794f4"),
("purple.400", "#9f7aea"),
("purple.500", "#805ad5"),
("purple.600", "#6b46c1"),
("purple.700", "#553c9a"),
("purple.800", "#44337a"),
("purple.900", "#322659"),
("pink.50", "#fff5f7"),
("pink.100", "#fed7e2"),
("pink.200", "#fbb6ce"),
("pink.300", "#f687b3"),
("pink.400", "#ed64a6"),
("pink.500", "#d53f8c"),
("pink.600", "#b83280"),
("pink.700", "#97266d"),
("pink.800", "#702459"),
("pink.900", "#521b41"),
];