///|
pub(all) enum TokenProblem {
EmptyName
EmptyValue(String)
UnsupportedColor(String, String)
UnsupportedLength(String, String)
UnsupportedShadow(String)
} derive(Debug, Eq)
///|
pub fn TokenProblem::message(self : TokenProblem) -> String {
match self {
EmptyName => "token name must not be empty"
EmptyValue(name) => "token `\{name}` must not have an empty value"
UnsupportedColor(name, value) =>
"color token `\{name}` should use a hex color, rgb(), rgba(), hsl(), hwb(), lab(), lch(), oklab(), oklch(), color(), transparent, currentColor, or a CSS variable; got `\{value}`"
UnsupportedLength(name, value) =>
"length token `\{name}` should use px, rem, em, %, vw, vh, svw, svh, lvw, lvh, dvw, dvh, ch, ex, cap, lh, rlh, vmin, vmax, or calc(); got `\{value}`"
UnsupportedShadow(name) =>
"shadow token `\{name}` should use none, a CSS variable, or a shadow expression with offsets"
}
}
///|
pub fn Token::validate(self : Token) -> Array[TokenProblem] {
let problems : Array[TokenProblem] = []
if self.name == "" {
problems.push(EmptyName)
}
if self.value == "" {
problems.push(EmptyValue(self.name))
}
match self.kind {
Color =>
if !is_color_value(self.value) {
problems.push(UnsupportedColor(self.name, self.value))
}
FontSize | Spacing | Radius =>
if !is_length_value(self.value) {
problems.push(UnsupportedLength(self.name, self.value))
}
Shadow =>
if !is_shadow_value(self.value) {
problems.push(UnsupportedShadow(self.name))
}
}
problems
}
///|
pub fn validate_all(tokens : Array[Token]) -> Array[TokenProblem] {
let problems : Array[TokenProblem] = []
for token in tokens {
for problem in token.validate() {
problems.push(problem)
}
}
problems
}
///|
fn is_color_value(value : String) -> Bool {
let normalized = value.trim().to_owned()
is_alias_value(normalized) ||
normalized == "transparent" ||
normalized == "currentColor" ||
normalized == "currentcolor" ||
is_hex_color(normalized) ||
is_css_function(normalized, "rgb") ||
is_css_function(normalized, "rgba") ||
is_css_function(normalized, "hsl") ||
is_css_function(normalized, "hsla") ||
is_css_function(normalized, "hwb") ||
is_css_function(normalized, "lab") ||
is_css_function(normalized, "lch") ||
is_css_function(normalized, "oklab") ||
is_css_function(normalized, "oklch") ||
is_css_function(normalized, "color") ||
is_css_function(normalized, "color-mix") ||
is_css_function(normalized, "var")
}
///|
fn is_length_value(value : String) -> Bool {
let normalized = value.trim().to_owned()
is_alias_value(normalized) ||
normalized == "0" ||
is_css_function(normalized, "calc") ||
is_css_function(normalized, "min") ||
is_css_function(normalized, "max") ||
is_css_function(normalized, "clamp") ||
is_css_function(normalized, "var") ||
is_length_literal(normalized)
}
///|
fn is_shadow_value(value : String) -> Bool {
let normalized = value.trim().to_owned()
if normalized == "none" || is_alias_value(normalized) {
true
} else if is_css_function(normalized, "var") {
true
} else {
let mut length_count = 0
for part in normalized.split(" ").to_array() {
let candidate = remove_trailing_comma(part.trim().to_owned())
if candidate != "" && is_length_value(candidate) {
length_count += 1
}
}
length_count >= 2 &&
!normalized.has_prefix(",") &&
!normalized.has_suffix(",")
}
}
///|
fn is_alias_value(value : String) -> Bool {
value.length() > 2 && value.has_prefix("{") && value.has_suffix("}")
}
///|
fn is_hex_color(value : String) -> Bool {
if !value.has_prefix("#") {
false
} else {
let digits = value[1:value.length()].to_owned()
let length = digits.length()
(length == 3 || length == 4 || length == 6 || length == 8) &&
all_hex_digits(digits)
}
}
///|
fn all_hex_digits(value : String) -> Bool {
for character in value {
let valid = (character >= '0' && character <= '9') ||
(character >= 'a' && character <= 'f') ||
(character >= 'A' && character <= 'F')
if !valid {
return false
}
}
true
}
///|
fn is_css_function(value : String, name : String) -> Bool {
let prefix = "\{name}("
if !value.has_prefix(prefix) || !value.has_suffix(")") {
false
} else {
let start = prefix.length()
let end = value.length() - 1
let body = value[start:end].to_owned().trim().to_owned()
body != "" && balanced_parentheses(value)
}
}
///|
fn remove_trailing_comma(value : String) -> String {
if value.has_suffix(",") {
value[0:value.length() - 1].to_owned()
} else {
value
}
}
///|
fn balanced_parentheses(value : String) -> Bool {
let mut depth = 0
for character in value {
if character == '(' {
depth += 1
} else if character == ')' {
depth -= 1
if depth < 0 {
return false
}
}
}
depth == 0
}
///|
fn is_length_literal(value : String) -> Bool {
let units = [
"svw", "svh", "lvw", "lvh", "dvw", "dvh", "vmin", "vmax", "rem", "cap", "rlh",
"px", "em", "vw", "vh", "ch", "ex", "lh", "%",
]
for unit in units {
if value.has_suffix(unit) {
let number = value[0:value.length() - unit.length()].to_owned()
return is_css_number(number)
}
}
false
}
///|
fn is_css_number(value : String) -> Bool {
let normalized = value.trim().to_owned()
if normalized == "" {
false
} else {
let mut digits = 0
let mut dots = 0
let mut position = 0
for character in normalized {
if (character == '+' || character == '-') && position == 0 {
position += 1
} else if character == '.' {
dots += 1
position += 1
} else if character >= '0' && character <= '9' {
digits += 1
position += 1
} else {
return false
}
}
digits > 0 && dots <= 1
}
}