use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Result, bail, ensure};
use regex::Regex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub(crate) fn tokens(input: &Path, out: &Path, mode: Option<&str>) -> Result<()> {
let raw = fs::read_to_string(input)?;
let token_file: TokensFile = serde_yaml::from_str(&raw)?;
let resolved = resolve_tokens(token_file, mode)?;
fs::create_dir_all(out)?;
let json_path = out.join("tokens.json");
fs::write(&json_path, serde_json::to_string_pretty(&resolved)?)?;
let css_path = out.join("tokens.css");
fs::write(&css_path, emit_css(&resolved))?;
let tailwind_path = out.join("tailwind.tokens.cjs");
fs::write(&tailwind_path, emit_tailwind(&resolved))?;
let theme_path = out.join("theme.rs");
fs::write(&theme_path, emit_theme_rs(&resolved)?)?;
println!(
"Generated tokens: {}, {}, {}, {}",
json_path.display(),
css_path.display(),
tailwind_path.display(),
theme_path.display()
);
Ok(())
}
pub(crate) fn compile(input: &Path, out: &Path, tokens: Option<&Path>) -> Result<()> {
let _ = (input, out, tokens);
bail!("svelte compile is not implemented yet")
}
pub(crate) fn wasm(out: &Path, name: Option<&str>) -> Result<()> {
let _ = (out, name);
bail!("svelte wasm is not implemented yet")
}
pub(crate) fn schema(out: Option<&Path>) -> Result<()> {
let schema = schemars::schema_for!(TokensFile);
let json = serde_json::to_string_pretty(&schema)?;
if let Some(path) = out {
fs::write(path, json)?;
} else {
println!("{}", json);
}
Ok(())
}
pub(crate) fn check(input: &Path, tokens: Option<&Path>, mode: Option<&str>) -> Result<()> {
let mut warnings = Vec::new();
let mut errors = Vec::new();
let mut validated_tokens = false;
let mut validated_sources = false;
if let Some(path) = resolve_tokens_path(input, tokens) {
let raw = fs::read_to_string(&path)?;
let token_file: TokensFile = serde_yaml::from_str(&raw)?;
validate_tokens_file(&token_file, mode, &mut warnings, &mut errors);
validated_tokens = true;
}
if let Some(paths) = resolve_svelte_sources(input)? {
validate_svelte_sources(&paths, &mut warnings, &mut errors)?;
validated_sources = true;
}
if !validated_tokens && !validated_sources {
bail!("no token or svelte sources provided");
}
if !validated_tokens {
warnings.push("token validation skipped (no token file provided)".to_string());
}
if !validated_sources {
warnings.push("svelte source validation skipped".to_string());
}
if !errors.is_empty() {
let mut msg = String::from("svelte check failed:");
for err in errors {
msg.push_str("\n- ");
msg.push_str(&err);
}
bail!(msg);
}
for warn in warnings {
eprintln!("warning: {}", warn);
}
println!("svelte check: ok");
Ok(())
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
struct TokensFile {
#[serde(default)]
version: Option<u8>,
#[serde(default)]
colors: BTreeMap<String, String>,
#[serde(default)]
spacing: BTreeMap<String, u32>,
#[serde(default)]
radii: BTreeMap<String, u32>,
#[serde(default)]
typography: BTreeMap<String, TypographyStyle>,
#[serde(default)]
motion: MotionTokens,
#[serde(default)]
fonts: BTreeMap<String, String>,
#[serde(default)]
modes: BTreeMap<String, ModeTokens>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
struct ModeTokens {
#[serde(default)]
colors: BTreeMap<String, String>,
#[serde(default)]
spacing: BTreeMap<String, u32>,
#[serde(default)]
radii: BTreeMap<String, u32>,
#[serde(default)]
typography: BTreeMap<String, TypographyStyle>,
#[serde(default)]
motion: MotionTokens,
#[serde(default)]
fonts: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
struct TypographyStyle {
#[serde(default)]
family: Option<String>,
#[serde(default)]
size: Option<u32>,
#[serde(default)]
weight: Option<u32>,
#[serde(default)]
line_height: Option<u32>,
#[serde(default)]
letter_spacing: Option<f32>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
struct MotionTokens {
#[serde(default)]
durations: BTreeMap<String, u32>,
#[serde(default)]
easings: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize)]
struct ResolvedTokens {
mode: Option<String>,
colors: BTreeMap<String, String>,
spacing: BTreeMap<String, u32>,
radii: BTreeMap<String, u32>,
typography: BTreeMap<String, TypographyStyle>,
motion: MotionTokens,
fonts: BTreeMap<String, String>,
}
fn resolve_tokens(mut tokens: TokensFile, mode: Option<&str>) -> Result<ResolvedTokens> {
let (chosen_mode, warning) = select_mode_key(&tokens.modes, mode)?;
if let Some(msg) = warning {
eprintln!("warning: {}", msg);
}
if let Some(mode_key) = &chosen_mode
&& let Some(mode_tokens) = tokens.modes.remove(mode_key)
{
merge_mode(&mut tokens, mode_tokens);
}
Ok(ResolvedTokens {
mode: chosen_mode,
colors: tokens.colors,
spacing: tokens.spacing,
radii: tokens.radii,
typography: tokens.typography,
motion: tokens.motion,
fonts: tokens.fonts,
})
}
fn merge_mode(base: &mut TokensFile, mode: ModeTokens) {
base.colors.extend(mode.colors);
base.spacing.extend(mode.spacing);
base.radii.extend(mode.radii);
base.typography.extend(mode.typography);
base.motion.durations.extend(mode.motion.durations);
base.motion.easings.extend(mode.motion.easings);
base.fonts.extend(mode.fonts);
}
fn select_mode_key(
modes: &BTreeMap<String, ModeTokens>,
mode: Option<&str>,
) -> Result<(Option<String>, Option<String>)> {
if modes.is_empty() {
return Ok((None, None));
}
if let Some(m) = mode {
ensure!(modes.contains_key(m), "unknown token mode '{}'", m);
return Ok((Some(m.to_string()), None));
}
if modes.contains_key("default") {
return Ok((Some("default".to_string()), None));
}
if modes.len() == 1 {
return Ok((Some(modes.keys().next().unwrap().to_string()), None));
}
let first = modes.keys().next().unwrap().to_string();
Ok((
Some(first.clone()),
Some(format!(
"multiple modes found; using '{}' (pass --mode to select)",
first
)),
))
}
fn resolve_tokens_path(input: &Path, tokens: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = tokens {
return Some(path.to_path_buf());
}
if is_token_file(input) {
return Some(input.to_path_buf());
}
None
}
fn resolve_svelte_sources(input: &Path) -> Result<Option<Vec<PathBuf>>> {
if is_svelte_file(input) {
return Ok(Some(vec![input.to_path_buf()]));
}
if input.is_dir() {
let files = collect_svelte_files(input)?;
if files.is_empty() {
return Ok(None);
}
return Ok(Some(files));
}
Ok(None)
}
fn collect_svelte_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(root) {
let entry = entry?;
if entry.file_type().is_file() {
let path = entry.path();
if is_svelte_file(path) {
files.push(path.to_path_buf());
}
}
}
Ok(files)
}
fn is_svelte_file(path: &Path) -> bool {
match path.extension().and_then(|ext| ext.to_str()) {
Some(ext) => ext.eq_ignore_ascii_case("svelte"),
None => false,
}
}
fn is_token_file(path: &Path) -> bool {
match path.extension().and_then(|ext| ext.to_str()) {
Some(ext) => {
let ext = ext.to_ascii_lowercase();
ext == "yml" || ext == "yaml"
}
None => false,
}
}
fn validate_tokens_file(
tokens: &TokensFile,
mode: Option<&str>,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(version) = tokens.version
&& version != 1
{
warnings.push(format!(
"token version {} is not recognized; expected 1",
version
));
}
validate_colors_map(&tokens.colors, "colors", errors);
validate_u8_map(&tokens.spacing, "spacing", errors);
validate_u8_map(&tokens.radii, "radii", errors);
validate_typography_map(&tokens.typography, "typography", errors);
validate_motion_tokens(&tokens.motion, "motion", errors);
validate_fonts_map(&tokens.fonts, "fonts", errors);
for (mode_name, mode_tokens) in &tokens.modes {
if !is_valid_key(mode_name) {
errors.push(format!(
"mode name '{}' must be lowercase letters, digits, '-' or '_'",
mode_name
));
}
let prefix = format!("modes.{}", mode_name);
validate_colors_map(&mode_tokens.colors, &format!("{prefix}.colors"), errors);
validate_u8_map(&mode_tokens.spacing, &format!("{prefix}.spacing"), errors);
validate_u8_map(&mode_tokens.radii, &format!("{prefix}.radii"), errors);
validate_typography_map(
&mode_tokens.typography,
&format!("{prefix}.typography"),
errors,
);
validate_motion_tokens(&mode_tokens.motion, &format!("{prefix}.motion"), errors);
validate_fonts_map(&mode_tokens.fonts, &format!("{prefix}.fonts"), errors);
}
let (selected_mode, warn) = match select_mode_key(&tokens.modes, mode) {
Ok(value) => value,
Err(err) => {
errors.push(err.to_string());
return;
}
};
if let Some(msg) = warn {
warnings.push(msg);
}
let resolved = resolve_for_mode(tokens, selected_mode.as_deref());
validate_required_tokens(&resolved, warnings);
}
fn resolve_for_mode(tokens: &TokensFile, mode: Option<&str>) -> ResolvedTokens {
let mut merged = tokens.clone();
let chosen = mode.map(|m| m.to_string());
if let Some(mode_key) = mode
&& let Some(mode_tokens) = merged.modes.remove(mode_key)
{
merge_mode(&mut merged, mode_tokens);
}
ResolvedTokens {
mode: chosen,
colors: merged.colors,
spacing: merged.spacing,
radii: merged.radii,
typography: merged.typography,
motion: merged.motion,
fonts: merged.fonts,
}
}
fn validate_required_tokens(tokens: &ResolvedTokens, warnings: &mut Vec<String>) {
warn_missing(&tokens.colors, "primary", "colors", warnings);
warn_missing(&tokens.colors, "background", "colors", warnings);
warn_missing(&tokens.colors, "text", "colors", warnings);
warn_missing(&tokens.spacing, "xs", "spacing", warnings);
warn_missing(&tokens.spacing, "sm", "spacing", warnings);
warn_missing(&tokens.spacing, "md", "spacing", warnings);
warn_missing(&tokens.spacing, "lg", "spacing", warnings);
warn_missing(&tokens.spacing, "xl", "spacing", warnings);
warn_missing(&tokens.radii, "none", "radii", warnings);
warn_missing(&tokens.radii, "sm", "radii", warnings);
warn_missing(&tokens.radii, "md", "radii", warnings);
warn_missing(&tokens.radii, "lg", "radii", warnings);
warn_missing(&tokens.radii, "full", "radii", warnings);
warn_missing(&tokens.fonts, "small", "fonts", warnings);
warn_missing(&tokens.fonts, "body", "fonts", warnings);
warn_missing(&tokens.fonts, "heading", "fonts", warnings);
}
fn warn_missing<T>(map: &BTreeMap<String, T>, key: &str, group: &str, warnings: &mut Vec<String>) {
if !map.contains_key(key) {
warnings.push(format!("missing {} token '{}'", group, key));
}
}
fn validate_colors_map(map: &BTreeMap<String, String>, group: &str, errors: &mut Vec<String>) {
validate_token_keys(map, group, errors);
for (name, value) in map {
if let Err(err) = parse_color(Some(value), (0, 0, 0, 255)) {
errors.push(format!("{} color '{}' invalid: {}", group, name, err));
}
}
}
fn validate_u8_map(map: &BTreeMap<String, u32>, group: &str, errors: &mut Vec<String>) {
validate_token_keys(map, group, errors);
for (name, value) in map {
if *value > u8::MAX as u32 {
errors.push(format!(
"{} token '{}' exceeds 255 (got {})",
group, name, value
));
}
}
}
fn validate_typography_map(
map: &BTreeMap<String, TypographyStyle>,
group: &str,
errors: &mut Vec<String>,
) {
validate_token_keys(map, group, errors);
for (name, style) in map {
if let Some(family) = &style.family
&& family.trim().is_empty()
{
errors.push(format!("{} '{}' has empty font family", group, name));
}
if let Some(size) = style.size
&& size == 0
{
errors.push(format!("{} '{}' has size 0", group, name));
}
if let Some(weight) = style.weight
&& weight == 0
{
errors.push(format!("{} '{}' has weight 0", group, name));
}
if let Some(height) = style.line_height
&& height == 0
{
errors.push(format!("{} '{}' has line_height 0", group, name));
}
if let Some(spacing) = style.letter_spacing
&& !spacing.is_finite()
{
errors.push(format!("{} '{}' has invalid letter_spacing", group, name));
}
}
}
fn validate_motion_tokens(tokens: &MotionTokens, group: &str, errors: &mut Vec<String>) {
validate_token_keys(&tokens.durations, &format!("{group}.durations"), errors);
validate_token_keys(&tokens.easings, &format!("{group}.easings"), errors);
for (name, value) in &tokens.easings {
if value.trim().is_empty() {
errors.push(format!("{} easing '{}' is empty", group, name));
}
}
}
fn validate_fonts_map(map: &BTreeMap<String, String>, group: &str, errors: &mut Vec<String>) {
validate_token_keys(map, group, errors);
for (name, value) in map {
if value.trim().is_empty() {
errors.push(format!("{} '{}' is empty", group, name));
}
}
}
fn validate_token_keys<T>(map: &BTreeMap<String, T>, group: &str, errors: &mut Vec<String>) {
for key in map.keys() {
if !is_valid_key(key) {
errors.push(format!(
"{} token key '{}' must be lowercase letters, digits, '-' or '_'",
group, key
));
}
}
}
fn is_valid_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
fn validate_svelte_sources(
files: &[PathBuf],
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) -> Result<()> {
for path in files {
let content = fs::read_to_string(path)?;
let stripped = strip_script_style(&content);
validate_svelte_content(path, &stripped, warnings, errors)?;
}
Ok(())
}
fn strip_script_style(input: &str) -> String {
let mut output = input.to_string();
for tag in ["script", "style"] {
let re = Regex::new(&format!(r"(?s)<{tag}\b[^>]*>.*?</{tag}>")).unwrap();
output = re.replace_all(&output, "").to_string();
}
output
}
fn validate_svelte_content(
path: &Path,
content: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) -> Result<()> {
if content.contains("{@html") {
errors.push(format!("{}: {{@html}} is not allowed", path.display()));
}
if content.contains("{#await") {
errors.push(format!("{}: {{#await}} is not allowed", path.display()));
}
if content.contains("use:") {
errors.push(format!(
"{}: use: directives are not allowed",
path.display()
));
}
if content.contains("transition:") || content.contains("in:") || content.contains("out:") {
errors.push(format!(
"{}: transition/in/out directives are not allowed",
path.display()
));
}
if content.contains("animate:") {
errors.push(format!(
"{}: animate: directives are not allowed",
path.display()
));
}
if Regex::new(r"\bslot\s*=")?.is_match(content) {
errors.push(format!("{}: named slots are not allowed", path.display()));
}
check_each_blocks(path, content, warnings)?;
check_tags(path, content, errors)?;
Ok(())
}
fn check_each_blocks(path: &Path, content: &str, warnings: &mut Vec<String>) -> Result<()> {
let re = Regex::new(r"\{#each[^}]*\}")?;
for cap in re.find_iter(content) {
let text = cap.as_str();
if !text.contains('(') || !text.contains(')') {
warnings.push(format!(
"{}: {{#each}} should include a key (e.g., {{#each items as item (item.id)}})",
path.display()
));
}
}
Ok(())
}
fn check_tags(path: &Path, content: &str, errors: &mut Vec<String>) -> Result<()> {
let re = Regex::new(r"<\s*/?\s*([A-Za-z][A-Za-z0-9:_-]*)")?;
for cap in re.captures_iter(content) {
let tag = cap.get(1).unwrap().as_str();
if tag.eq_ignore_ascii_case("script") || tag.eq_ignore_ascii_case("style") {
continue;
}
if tag.eq_ignore_ascii_case("slot") {
continue;
}
if tag.contains(':') {
errors.push(format!(
"{}: namespaced tag '{}' is not allowed",
path.display(),
tag
));
continue;
}
if tag
.chars()
.next()
.map(|c| c.is_ascii_lowercase())
.unwrap_or(false)
{
errors.push(format!(
"{}: raw HTML tag '{}' is not allowed",
path.display(),
tag
));
continue;
}
if !is_allowed_component(tag) {
errors.push(format!(
"{}: component '{}' is not allowed",
path.display(),
tag
));
}
}
Ok(())
}
fn is_allowed_component(tag: &str) -> bool {
matches!(
tag,
"Button" | "Text" | "Image" | "Stack" | "Row" | "Column"
)
}
fn emit_css(tokens: &ResolvedTokens) -> String {
let mut out = String::from("/* rlvgl-creator generated tokens */\n:root {\n");
for (name, value) in &tokens.colors {
out.push_str(&format!(" --rlvgl-color-{}: {};\n", name, value));
}
for (name, value) in &tokens.spacing {
out.push_str(&format!(" --rlvgl-spacing-{}: {}px;\n", name, value));
}
for (name, value) in &tokens.radii {
out.push_str(&format!(" --rlvgl-radius-{}: {}px;\n", name, value));
}
for (name, style) in &tokens.typography {
if let Some(family) = &style.family {
out.push_str(&format!(
" --rlvgl-font-{}-family: \"{}\";\n",
name,
escape_css_string(family)
));
}
if let Some(size) = style.size {
out.push_str(&format!(" --rlvgl-font-{}-size: {}px;\n", name, size));
}
if let Some(weight) = style.weight {
out.push_str(&format!(" --rlvgl-font-{}-weight: {};\n", name, weight));
}
if let Some(height) = style.line_height {
out.push_str(&format!(
" --rlvgl-font-{}-line-height: {}px;\n",
name, height
));
}
if let Some(spacing) = style.letter_spacing {
out.push_str(&format!(
" --rlvgl-font-{}-letter-spacing: {}em;\n",
name, spacing
));
}
}
for (name, value) in &tokens.motion.durations {
out.push_str(&format!(
" --rlvgl-motion-duration-{}: {}ms;\n",
name, value
));
}
for (name, value) in &tokens.motion.easings {
out.push_str(&format!(" --rlvgl-motion-easing-{}: {};\n", name, value));
}
out.push_str("}\n");
out
}
fn emit_tailwind(tokens: &ResolvedTokens) -> String {
let mut out = String::from("module.exports = {\n theme: {\n extend: {\n");
if !tokens.colors.is_empty() {
out.push_str(" colors: {\n");
for (name, value) in &tokens.colors {
out.push_str(&format!(" \"{}\": \"{}\",\n", name, value));
}
out.push_str(" },\n");
}
if !tokens.spacing.is_empty() {
out.push_str(" spacing: {\n");
for (name, value) in &tokens.spacing {
out.push_str(&format!(" \"{}\": \"{}px\",\n", name, value));
}
out.push_str(" },\n");
}
if !tokens.radii.is_empty() {
out.push_str(" borderRadius: {\n");
for (name, value) in &tokens.radii {
out.push_str(&format!(" \"{}\": \"{}px\",\n", name, value));
}
out.push_str(" },\n");
}
if !tokens.typography.is_empty() {
out.push_str(" fontFamily: {\n");
for (name, style) in &tokens.typography {
if let Some(family) = &style.family {
out.push_str(&format!(
" \"{}\": [\"{}\"],\n",
name,
escape_js_string(family)
));
}
}
out.push_str(" },\n");
out.push_str(" fontSize: {\n");
for (name, style) in &tokens.typography {
if let Some(size) = style.size {
if style.line_height.is_some()
|| style.letter_spacing.is_some()
|| style.weight.is_some()
{
out.push_str(&format!(" \"{}\": [\"{}px\", {{", name, size));
if let Some(height) = style.line_height {
out.push_str(&format!(" lineHeight: \"{}px\",", height));
}
if let Some(spacing) = style.letter_spacing {
out.push_str(&format!(" letterSpacing: \"{}em\",", spacing));
}
if let Some(weight) = style.weight {
out.push_str(&format!(" fontWeight: \"{}\",", weight));
}
out.push_str(" }],\n");
} else {
out.push_str(&format!(" \"{}\": \"{}px\",\n", name, size));
}
}
}
out.push_str(" },\n");
}
if !tokens.motion.durations.is_empty() {
out.push_str(" transitionDuration: {\n");
for (name, value) in &tokens.motion.durations {
out.push_str(&format!(" \"{}\": \"{}ms\",\n", name, value));
}
out.push_str(" },\n");
}
if !tokens.motion.easings.is_empty() {
out.push_str(" transitionTimingFunction: {\n");
for (name, value) in &tokens.motion.easings {
out.push_str(&format!(" \"{}\": \"{}\",\n", name, value));
}
out.push_str(" },\n");
}
out.push_str(" },\n },\n};\n");
out
}
fn emit_theme_rs(tokens: &ResolvedTokens) -> Result<String> {
let spacing = resolve_spacing(tokens)?;
let radii = resolve_radii(tokens)?;
let colors = resolve_colors(tokens)?;
let fonts = resolve_fonts(tokens);
Ok(format!(
"// SPDX-License-Identifier: MIT\n\
//! rlvgl-creator generated theme tokens from shared tokens.\n\
//!\n\
//! This file is meant to be included by user crates.\n\
\n\
use rlvgl_ui::style::Color;\n\
use rlvgl_ui::theme::{{Colors, Fonts, Radii, Spacing, Theme, Tokens}};\n\
\n\
pub const THEME: Theme = Theme {{\n\
tokens: Tokens {{\n\
spacing: Spacing {{ xs: {sx}, sm: {sm}, md: {md}, lg: {lg}, xl: {xl} }},\n\
radii: Radii {{ none: {rn}, sm: {rs}, md: {rm}, lg: {rl}, full: {rf} }},\n\
colors: Colors {{\n\
primary: Color({cr}, {cg}, {cb}, {ca}),\n\
background: Color({br}, {bg}, {bb}, {ba}),\n\
text: Color({tr}, {tg}, {tb}, {ta}),\n\
}},\n\
fonts: Fonts {{ small: \"{fs}\", body: \"{fb}\", heading: \"{fh}\" }},\n\
}},\n\
}};\n",
sx = spacing.xs,
sm = spacing.sm,
md = spacing.md,
lg = spacing.lg,
xl = spacing.xl,
rn = radii.none,
rs = radii.sm,
rm = radii.md,
rl = radii.lg,
rf = radii.full,
cr = colors.primary.0,
cg = colors.primary.1,
cb = colors.primary.2,
ca = colors.primary.3,
br = colors.background.0,
bg = colors.background.1,
bb = colors.background.2,
ba = colors.background.3,
tr = colors.text.0,
tg = colors.text.1,
tb = colors.text.2,
ta = colors.text.3,
fs = escape_rust_string(&fonts.small),
fb = escape_rust_string(&fonts.body),
fh = escape_rust_string(&fonts.heading),
))
}
#[derive(Clone, Copy)]
struct ThemeSpacing {
xs: u8,
sm: u8,
md: u8,
lg: u8,
xl: u8,
}
#[derive(Clone, Copy)]
struct ThemeRadii {
none: u8,
sm: u8,
md: u8,
lg: u8,
full: u8,
}
#[derive(Clone, Copy)]
struct ThemeColors {
primary: (u8, u8, u8, u8),
background: (u8, u8, u8, u8),
text: (u8, u8, u8, u8),
}
#[derive(Clone)]
struct ThemeFonts {
small: String,
body: String,
heading: String,
}
fn resolve_spacing(tokens: &ResolvedTokens) -> Result<ThemeSpacing> {
let xs = pick_u8(&tokens.spacing, "xs", 2)?;
let sm = pick_u8(&tokens.spacing, "sm", 4)?;
let md = pick_u8(&tokens.spacing, "md", 8)?;
let lg = pick_u8(&tokens.spacing, "lg", 16)?;
let xl = pick_u8(&tokens.spacing, "xl", 24)?;
Ok(ThemeSpacing { xs, sm, md, lg, xl })
}
fn resolve_radii(tokens: &ResolvedTokens) -> Result<ThemeRadii> {
let none = pick_u8(&tokens.radii, "none", 0)?;
let sm = pick_u8(&tokens.radii, "sm", 2)?;
let md = pick_u8(&tokens.radii, "md", 4)?;
let lg = pick_u8(&tokens.radii, "lg", 8)?;
let full = pick_u8(&tokens.radii, "full", 255)?;
Ok(ThemeRadii {
none,
sm,
md,
lg,
full,
})
}
fn resolve_colors(tokens: &ResolvedTokens) -> Result<ThemeColors> {
let primary = parse_color(tokens.colors.get("primary"), (98, 0, 238, 255))?;
let background = parse_color(tokens.colors.get("background"), (255, 255, 255, 255))?;
let text = parse_color(tokens.colors.get("text"), (0, 0, 0, 255))?;
Ok(ThemeColors {
primary,
background,
text,
})
}
fn resolve_fonts(tokens: &ResolvedTokens) -> ThemeFonts {
let small = tokens
.fonts
.get("small")
.cloned()
.unwrap_or_else(|| "tiny".to_string());
let body = tokens
.fonts
.get("body")
.cloned()
.unwrap_or_else(|| "default".to_string());
let heading = tokens
.fonts
.get("heading")
.cloned()
.unwrap_or_else(|| "bold".to_string());
ThemeFonts {
small,
body,
heading,
}
}
fn pick_u8(map: &BTreeMap<String, u32>, key: &str, fallback: u8) -> Result<u8> {
match map.get(key) {
Some(v) if *v <= u8::MAX as u32 => Ok(*v as u8),
Some(v) => bail!("token '{}' exceeds u8 range: {}", key, v),
None => Ok(fallback),
}
}
fn parse_color(input: Option<&String>, fallback: (u8, u8, u8, u8)) -> Result<(u8, u8, u8, u8)> {
let Some(raw) = input else {
return Ok(fallback);
};
let value = raw.trim();
if let Some(hex) = value.strip_prefix('#') {
return parse_hex_color(hex);
}
if let Some(parsed) = parse_rgb_function(value)? {
return Ok(parsed);
}
bail!("unsupported color format '{}'", value)
}
fn parse_hex_color(hex: &str) -> Result<(u8, u8, u8, u8)> {
let bytes = match hex.len() {
6 => {
let r = u8::from_str_radix(&hex[0..2], 16)?;
let g = u8::from_str_radix(&hex[2..4], 16)?;
let b = u8::from_str_radix(&hex[4..6], 16)?;
(r, g, b, 255)
}
8 => {
let r = u8::from_str_radix(&hex[0..2], 16)?;
let g = u8::from_str_radix(&hex[2..4], 16)?;
let b = u8::from_str_radix(&hex[4..6], 16)?;
let a = u8::from_str_radix(&hex[6..8], 16)?;
(r, g, b, a)
}
_ => bail!("invalid hex color '{}'", hex),
};
Ok(bytes)
}
fn parse_rgb_function(input: &str) -> Result<Option<(u8, u8, u8, u8)>> {
let raw = input.trim();
let (is_rgba, body) = if let Some(rest) = raw.strip_prefix("rgba(") {
(true, rest)
} else if let Some(rest) = raw.strip_prefix("rgb(") {
(false, rest)
} else {
return Ok(None);
};
let body = body
.strip_suffix(')')
.ok_or_else(|| anyhow::anyhow!("invalid color"))?;
let parts: Vec<&str> = body.split(',').map(|p| p.trim()).collect();
ensure!(
parts.len() == if is_rgba { 4 } else { 3 },
"invalid color '{}' ",
input
);
let r = parts[0].parse::<u8>()?;
let g = parts[1].parse::<u8>()?;
let b = parts[2].parse::<u8>()?;
let a = if is_rgba {
let raw_a = parts[3].parse::<f32>()?;
if raw_a <= 1.0 {
(raw_a * 255.0).round() as u8
} else {
raw_a as u8
}
} else {
255
};
Ok(Some((r, g, b, a)))
}
fn escape_css_string(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
fn escape_js_string(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
fn escape_rust_string(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}