Menu
Public repository / master
AkurAI-Framework / crates/css/src/utilities/mod.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
//! The utility grammar: a bare utility (no variants) → CSS declarations.
//!
//! [`resolve`] maps a utility such as `px-4`, `bg-accent/90` or `w-[220px]` to a
//! [`Utility`]: its declarations plus, for a few families, a child selector
//! suffix (`space-x-2`, `divide-y`), registration/keyframe blocks it needs
//! emitted once ([`Utility::extras`]) and an ordering tier ([`Utility::tier`]).
//! Anything we don't define returns `None` and is omitted by the generator.
//!
//! Families live in submodules and are tried in a fixed order; each one is
//! precise about what it accepts, so `text-sm` (size) and `text-accent`
//! (colour), or `border-2` (width) and `border-border` (colour), never clash.
//! A leading `-` routes to the families that allow negative values; a fully
//! bracketed `[prop:value]` is an arbitrary property.
mod border;
mod color;
mod effects;
mod flex_grid;
mod interact;
mod layout;
mod sizing;
mod spacing;
mod typography;
pub mod value;
/// Selector suffix targeting every non-hidden child after the first; used by
/// `space-*` and `divide-*`.
pub const BETWEEN_CHILDREN: &str = " > :not([hidden]) ~ :not([hidden])";
/// One resolved utility.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Utility {
/// `prop:value` declarations, in output order.
pub decls: Vec<String>,
/// Selector suffix appended after the class (and its variant pseudo
/// classes), e.g. [`BETWEEN_CHILDREN`] or `::placeholder`.
pub child: Option<&'static str>,
/// Top-level blocks this utility needs emitted once at the end of the
/// stylesheet: `@keyframes` and `@property` registrations.
pub extras: Vec<&'static str>,
/// Cascade tier for canonical ordering: 0 = shorthand (`p-4`), 1 = axis
/// (`px-2`), 2 = single side/corner (`pt-1`). Higher tiers are emitted later
/// so a longhand overrides a shorthand on the same element.
pub tier: u8,
}
impl Utility {
/// Build from a `;`-separated body of fixed declarations.
pub fn body(body: &str) -> Self {
Self::from_decls(body.split(';').map(str::to_string).collect())
}
/// One declaration.
pub fn decl(prop: &str, value: &str) -> Self {
Self::props(&[prop], value)
}
/// The same value on several properties (`px-2` ⇒ left and right).
pub fn props(props: &[&str], value: &str) -> Self {
Self::from_decls(props.iter().map(|p| format!("{p}:{value}")).collect())
}
/// From pre-formatted `prop:value` declarations.
pub fn from_decls(decls: Vec<String>) -> Self {
Utility {
decls,
child: None,
extras: Vec::new(),
tier: 0,
}
}
pub fn child(mut self, suffix: &'static str) -> Self {
self.child = Some(suffix);
self
}
pub fn extras(mut self, extras: &[&'static str]) -> Self {
self.extras.extend_from_slice(extras);
self
}
pub fn tier(mut self, tier: u8) -> Self {
self.tier = tier;
self
}
/// The declaration block body: `a:b;c:d`.
pub fn declaration(&self) -> String {
self.decls.join(";")
}
}
/// A family parser: utility text → resolved utility.
type Family = fn(&str) -> Option<Utility>;
/// Families tried for a plain utility, in order. Colour comes last so every
/// keyword/size reading wins over a colour reading of the same prefix.
const FAMILIES: &[Family] = &[
layout::resolve,
spacing::resolve,
sizing::resolve,
flex_grid::resolve,
typography::resolve,
border::resolve,
effects::resolve,
interact::resolve,
color::resolve,
];
/// Families that accept a leading `-` (`-mt-2`, `-translate-x-1/2`).
const NEGATABLE: &[Family] = &[
spacing::negative,
layout::negative,
flex_grid::negative,
typography::negative,
effects::negative,
];
/// Resolve a bare utility (variants and `!` already stripped).
pub fn resolve(utility: &str) -> Option<Utility> {
if let Some(rest) = utility.strip_prefix('-') {
return NEGATABLE.iter().find_map(|family| family(rest));
}
if utility.starts_with('[') {
return arbitrary_property(utility);
}
FAMILIES.iter().find_map(|family| family(utility))
}
/// Return the CSS declaration body for a bare utility `class`, or `None` if it
/// is not a known utility.
///
/// The body has no surrounding braces and no trailing semicolon, e.g.
/// `declaration("p-2") == Some("padding:8px")`.
pub fn declaration(class: &str) -> Option<String> {
resolve(class).map(|u| u.declaration())
}
/// `[mask-type:luminance]` ⇒ `mask-type:luminance`. The property must be a
/// lowercase CSS identifier (custom properties `--x` allowed).
fn arbitrary_property(utility: &str) -> Option<Utility> {
let decoded = value::arbitrary(utility)?;
let (prop, val) = decoded.split_once(':')?;
let valid_prop = !prop.is_empty()
&& prop
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !prop.starts_with(|c: char| c.is_ascii_digit());
if !valid_prop || val.trim().is_empty() {
return None;
}
Some(Utility::decl(prop, val))
}
/// `@property` registration text for a composed custom property: non-inheriting
/// so a parent's `shadow`/`ring`/`translate` state never leaks into children.
macro_rules! register {
($name:literal) => {
concat!("@property ", $name, "{syntax:\"*\";inherits:false}")
};
}
pub(crate) use register;
/// Try `{prefix}-{value}` for each `(prefix, props)` row (longest prefixes
/// first), parsing the value with `parse`. Returns the first row that parses.
fn prefixed(
utility: &str,
rows: &[(&str, &[&str], u8)],
parse: impl Fn(&str) -> Option<String>,
) -> Option<Utility> {
rows.iter().find_map(|&(prefix, props, tier)| {
let v = utility.strip_prefix(prefix)?.strip_prefix('-')?;
Some(Utility::props(props, &parse(v)?).tier(tier))
})
}
/// Look `key` up in a `(key, body)` keyword table.
fn keyword(table: &[(&str, &str)], key: &str) -> Option<Utility> {
table
.iter()
.find(|(k, _)| *k == key)
.map(|(_, body)| Utility::body(body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spacing_scale() {
assert_eq!(declaration("p-2").as_deref(), Some("padding:8px"));
assert_eq!(declaration("p-0").as_deref(), Some("padding:0px"));
assert_eq!(declaration("m-4").as_deref(), Some("margin:16px"));
assert_eq!(declaration("gap-1").as_deref(), Some("gap:4px"));
}
#[test]
fn axis_spacing_emits_two_properties() {
assert_eq!(
declaration("px-2").as_deref(),
Some("padding-left:8px;padding-right:8px")
);
assert_eq!(
declaration("py-3").as_deref(),
Some("padding-top:12px;padding-bottom:12px")
);
}
#[test]
fn keyword_utilities() {
assert_eq!(declaration("flex").as_deref(), Some("display:flex"));
assert_eq!(declaration("hidden").as_deref(), Some("display:none"));
assert_eq!(declaration("font-bold").as_deref(), Some("font-weight:700"));
assert_eq!(
declaration("text-accent").as_deref(),
Some("color:var(--accent)")
);
}
#[test]
fn unknown_classes_are_none() {
assert_eq!(declaration("btn-primary"), None);
assert_eq!(declaration("p-"), None);
assert_eq!(declaration("p-x"), None);
assert_eq!(declaration("p--1"), None);
assert_eq!(declaration("p-01"), None);
assert_eq!(declaration("zzz-2"), None);
}
#[test]
fn tiers_rank_shorthand_axis_side() {
assert_eq!(resolve("p-4").unwrap().tier, 0);
assert_eq!(resolve("px-4").unwrap().tier, 1);
assert_eq!(resolve("pt-4").unwrap().tier, 2);
assert_eq!(resolve("rounded-tl").unwrap().tier, 2);
}
#[test]
fn arbitrary_property_validation() {
assert_eq!(
declaration("[mask-type:luminance]").as_deref(),
Some("mask-type:luminance")
);
assert_eq!(declaration("[--gap:4px]").as_deref(), Some("--gap:4px"));
assert_eq!(declaration("[x]"), None);
assert_eq!(declaration("[X:y]"), None);
assert_eq!(declaration("[a:b;c:d]"), None);
}
}