AkurAI Build
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);
    }
}