use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TokenValue {
Color(String),
Size(String),
Font(String),
Number(f64),
Bool(bool),
String(String),
}
impl TokenValue {
pub fn to_typst(&self) -> String {
match self {
TokenValue::Color(c) => {
if c.starts_with('#') {
format!("rgb(\"{}\")", c)
} else {
c.clone()
}
}
TokenValue::Size(s) => s.clone(),
TokenValue::Font(f) => format!("\"{}\"", f),
TokenValue::Number(n) => n.to_string(),
TokenValue::Bool(b) => b.to_string(),
TokenValue::String(s) => format!("\"{}\"", s),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeTokens {
#[serde(flatten)]
tokens: HashMap<String, TokenValue>,
}
impl ThemeTokens {
pub fn new() -> Self {
Self {
tokens: HashMap::new(),
}
}
pub fn get(&self, key: &str) -> Option<&TokenValue> {
self.tokens.get(key)
}
pub fn set(&mut self, key: impl Into<String>, value: TokenValue) {
self.tokens.insert(key.into(), value);
}
pub fn merge(&mut self, other: &ThemeTokens) {
for (key, value) in &other.tokens {
self.tokens.insert(key.clone(), value.clone());
}
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &TokenValue)> {
self.tokens.iter()
}
pub fn to_typst_definitions(&self) -> String {
let mut lines = Vec::new();
for (key, value) in &self.tokens {
let var_name = key.replace('.', "-");
lines.push(format!("#let {} = {}", var_name, value.to_typst()));
}
lines.sort();
lines.join("\n")
}
}
impl Default for ThemeTokens {
fn default() -> Self {
use token_names::*;
let mut tokens = Self::new();
tokens.set(COLOR_PRIMARY, TokenValue::Color("#155EEF".into()));
tokens.set(COLOR_SECONDARY, TokenValue::Color("#667085".into()));
tokens.set(COLOR_TEXT, TokenValue::Color("#101828".into()));
tokens.set(COLOR_TEXT_MUTED, TokenValue::Color("#667085".into()));
tokens.set(COLOR_BACKGROUND, TokenValue::Color("#ffffff".into()));
tokens.set(COLOR_SURFACE, TokenValue::Color("#ffffff".into()));
tokens.set(COLOR_SURFACE_SOFT, TokenValue::Color("#F8FAFC".into()));
tokens.set(COLOR_SURFACE_ALT, TokenValue::Color("#F2F4F7".into()));
tokens.set(COLOR_BORDER, TokenValue::Color("#E4E7EC".into()));
tokens.set(COLOR_OK, TokenValue::Color("#12B76A".into()));
tokens.set(COLOR_OK_SOFT, TokenValue::Color("#ECFDF3".into()));
tokens.set(COLOR_WARN, TokenValue::Color("#F79009".into()));
tokens.set(COLOR_WARN_SOFT, TokenValue::Color("#FEF0C7".into()));
tokens.set(COLOR_BAD, TokenValue::Color("#D92D20".into()));
tokens.set(COLOR_BAD_SOFT, TokenValue::Color("#FEE4E2".into()));
tokens.set(COLOR_ACCENT_SOFT, TokenValue::Color("#EAF2FF".into()));
tokens.set(COLOR_INFO, TokenValue::Color("#1570EF".into()));
tokens.set(COLOR_INFO_SOFT, TokenValue::Color("#EFF8FF".into()));
tokens.set(FONT_BODY, TokenValue::Font("Helvetica Neue".into()));
tokens.set(FONT_HEADING, TokenValue::Font("Helvetica Neue".into()));
tokens.set(FONT_MONO, TokenValue::Font("Menlo".into()));
tokens.set(FONT_SIZE_XS, TokenValue::Size("8.5pt".into()));
tokens.set(FONT_SIZE_SM, TokenValue::Size("8.8pt".into()));
tokens.set(FONT_SIZE_BASE, TokenValue::Size("10.5pt".into()));
tokens.set(FONT_SIZE_LG, TokenValue::Size("13pt".into()));
tokens.set(FONT_SIZE_XL, TokenValue::Size("18pt".into()));
tokens.set(FONT_SIZE_2XL, TokenValue::Size("24pt".into()));
tokens.set(FONT_SIZE_3XL, TokenValue::Size("34pt".into()));
tokens.set(SPACING_1, TokenValue::Size("4pt".into()));
tokens.set(SPACING_2, TokenValue::Size("6pt".into()));
tokens.set(SPACING_3, TokenValue::Size("10pt".into()));
tokens.set(SPACING_4, TokenValue::Size("14pt".into()));
tokens.set(SPACING_5, TokenValue::Size("20pt".into()));
tokens.set(SPACING_6, TokenValue::Size("28pt".into()));
tokens.set(SPACING_7, TokenValue::Size("40pt".into()));
tokens.set("table.header-bg", TokenValue::Color("#F2F4F7".into()));
tokens.set("table.row-alt-bg", TokenValue::Color("#F8FAFC".into()));
tokens.set("table.border", TokenValue::Color("#E4E7EC".into()));
tokens.set("table.border-width", TokenValue::Size("0.5pt".into()));
tokens.set("page.margin", TokenValue::Size("18mm".into()));
tokens.set("page.margin-top", TokenValue::Size("18mm".into()));
tokens.set("page.margin-bottom", TokenValue::Size("16mm".into()));
tokens.set("page.header-height", TokenValue::Size("1.5cm".into()));
tokens.set("page.footer-height", TokenValue::Size("1cm".into()));
tokens.set(
"component.score-card.radius",
TokenValue::Size("10pt".into()),
);
tokens.set("component.finding.radius", TokenValue::Size("10pt".into()));
tokens.set("component.callout.radius", TokenValue::Size("10pt".into()));
tokens.set(
"component.card.border-width",
TokenValue::Size("0.8pt".into()),
);
tokens
}
}
pub mod token_names {
pub const COLOR_PRIMARY: &str = "color.primary";
pub const COLOR_SECONDARY: &str = "color.secondary";
pub const COLOR_TEXT: &str = "color.text";
pub const COLOR_TEXT_MUTED: &str = "color.text-muted";
pub const COLOR_BACKGROUND: &str = "color.background";
pub const COLOR_SURFACE: &str = "color.surface";
pub const COLOR_SURFACE_SOFT: &str = "color.surface-soft";
pub const COLOR_SURFACE_ALT: &str = "color.surface-alt";
pub const COLOR_BORDER: &str = "color.border";
pub const COLOR_OK: &str = "color.ok";
pub const COLOR_OK_SOFT: &str = "color.ok-soft";
pub const COLOR_WARN: &str = "color.warn";
pub const COLOR_WARN_SOFT: &str = "color.warn-soft";
pub const COLOR_BAD: &str = "color.bad";
pub const COLOR_BAD_SOFT: &str = "color.bad-soft";
pub const COLOR_ACCENT_SOFT: &str = "color.accent-soft";
pub const COLOR_INFO: &str = "color.info";
pub const COLOR_INFO_SOFT: &str = "color.info-soft";
pub const FONT_BODY: &str = "font.body";
pub const FONT_HEADING: &str = "font.heading";
pub const FONT_MONO: &str = "font.mono";
pub const FONT_SIZE_XS: &str = "font.size.xs";
pub const FONT_SIZE_SM: &str = "font.size.sm";
pub const FONT_SIZE_BASE: &str = "font.size.base";
pub const FONT_SIZE_LG: &str = "font.size.lg";
pub const FONT_SIZE_XL: &str = "font.size.xl";
pub const FONT_SIZE_2XL: &str = "font.size.2xl";
pub const FONT_SIZE_3XL: &str = "font.size.3xl";
pub const SPACING_1: &str = "spacing.1";
pub const SPACING_2: &str = "spacing.2";
pub const SPACING_3: &str = "spacing.3";
pub const SPACING_4: &str = "spacing.4";
pub const SPACING_5: &str = "spacing.5";
pub const SPACING_6: &str = "spacing.6";
pub const SPACING_7: &str = "spacing.7";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_to_typst() {
assert_eq!(
TokenValue::Color("#ff0000".into()).to_typst(),
"rgb(\"#ff0000\")"
);
assert_eq!(TokenValue::Size("12pt".into()).to_typst(), "12pt");
assert_eq!(TokenValue::Number(42.0).to_typst(), "42");
}
#[test]
fn test_default_tokens() {
let tokens = ThemeTokens::default();
assert!(tokens.get("color.primary").is_some());
assert!(tokens.get("font.body").is_some());
assert!(tokens.get("spacing.4").is_some());
}
}