AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/css/src/generate.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Stylesheet generation: used class tokens → compact CSS.
//!
//! Each recognised class becomes one compact rule `selector{declarations}`;
//! unknown classes are skipped. Output has no whitespace between rules and is
//! fully deterministic. Rule order:
//!
//! 1. base utilities (no variants), then
//! 2. state/attribute variant utilities in Tailwind's precedence order
//!    (`hover` < `focus` < `active` < `disabled` < `group-*` < `aria-*` <
//!    `data-*`), then
//! 3. media blocks — `dark`/`motion-*`/`print` first, then the breakpoints in
//!    ascending width — with consecutive rules sharing one `@media` block, and
//! 4. the `@property` registrations and `@keyframes` the used utilities need,
//!    each once.
//!
//! Within a group rules keep first-seen order ([`stylesheet`]) or, in
//! canonical order ([`stylesheet_canonical`]), shorthands come before axis and
//! single-side longhands so `p-4 px-2` behaves like Tailwind regardless of
//! where each class was first seen.

use crate::escape::class_selector;
use crate::utilities;
use crate::variants::{split, Context};

/// How rules inside one group are ordered.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Order {
    /// Strictly first-seen.
    FirstSeen,
    /// Shorthand → axis → side tiers, then first-seen.
    Canonical,
}

/// One rendered rule plus its sort key.
struct Rule {
    /// `(breakpoint, media bits, variant rank, tier, first-seen index)`.
    key: (u8, u8, u8, u8, usize),
    media: Option<String>,
    css: String,
    extras: Vec<&'static str>,
}

/// Build a compact stylesheet for `classes`, keeping only recognised utilities.
/// Base rules keep the input (first-seen) order.
pub fn stylesheet(classes: &[String]) -> String {
    render(classes, Order::FirstSeen)
}

/// Like [`stylesheet`], but orders shorthands before longhands within each
/// group (Tailwind's cascade), so the later, more specific utility wins.
pub fn stylesheet_canonical(classes: &[String]) -> String {
    render(classes, Order::Canonical)
}

/// Render one class to its standalone CSS (including any `@media` wrapper),
/// or `None` if it is not a recognised utility. Extras are not included.
pub fn rule(class: &str) -> Option<String> {
    let r = build_rule(class, 0, Order::FirstSeen)?;
    Some(match r.media {
        Some(q) => format!("@media {q}{{{}}}", r.css),
        None => r.css,
    })
}

fn render(classes: &[String], order: Order) -> String {
    let mut rules: Vec<Rule> = classes
        .iter()
        .enumerate()
        .filter_map(|(i, class)| build_rule(class, i, order))
        .collect();
    rules.sort_by_key(|r| r.key);

    let mut out = String::new();
    let mut open: Option<&str> = None;
    for rule in &rules {
        let media = rule.media.as_deref();
        if media != open {
            if open.is_some() {
                out.push('}');
            }
            if let Some(q) = media {
                out.push_str("@media ");
                out.push_str(q);
                out.push('{');
            }
            open = media;
        }
        out.push_str(&rule.css);
    }
    if open.is_some() {
        out.push('}');
    }

    let mut extras: Vec<&str> = Vec::new();
    for extra in rules.iter().flat_map(|r| r.extras.iter().copied()) {
        if !extras.contains(&extra) {
            extras.push(extra);
        }
    }
    extras.sort_by_key(|e| !e.starts_with("@property"));
    for extra in extras {
        out.push_str(extra);
    }
    out
}

fn build_rule(class: &str, seen: usize, order: Order) -> Option<Rule> {
    let parsed = split(class)?;
    let utility = utilities::resolve(parsed.utility)?;
    let mut ctx = Context::default();
    for variant in &parsed.variants {
        ctx.apply(variant)?;
    }

    let suffix = if parsed.important { "!important" } else { "" };
    let body = utility
        .decls
        .iter()
        .map(|d| format!("{d}{suffix}"))
        .collect::<Vec<_>>()
        .join(";");
    let selector = ctx.selector(&class_selector(class), utility.child);
    let tier = if order == Order::Canonical {
        utility.tier
    } else {
        0
    };
    Some(Rule {
        key: (ctx.breakpoint, ctx.media, ctx.rank, tier, seen),
        media: ctx.media_query(),
        css: format!("{selector}{{{body}}}"),
        extras: utility.extras,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn owned(items: &[&str]) -> Vec<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn emits_only_known_utilities_in_order() {
        let css = stylesheet(&owned(&["flex", "btn", "p-2"]));
        assert_eq!(css, ".flex{display:flex}.p-2{padding:8px}");
    }

    #[test]
    fn empty_input_yields_empty_string() {
        assert_eq!(stylesheet(&[]), "");
    }

    #[test]
    fn all_unknown_yields_empty_string() {
        assert_eq!(stylesheet(&owned(&["card", "btn-primary"])), "");
    }

    #[test]
    fn first_seen_keeps_longhand_position() {
        let classes = owned(&["px-2", "p-4"]);
        assert_eq!(
            stylesheet(&classes),
            ".px-2{padding-left:8px;padding-right:8px}.p-4{padding:16px}"
        );
        assert_eq!(
            stylesheet_canonical(&classes),
            ".p-4{padding:16px}.px-2{padding-left:8px;padding-right:8px}"
        );
    }

    #[test]
    fn single_rule_rendering() {
        assert_eq!(rule("p-2").as_deref(), Some(".p-2{padding:8px}"));
        assert_eq!(
            rule("sm:p-2").as_deref(),
            Some(r"@media (min-width:640px){.sm\:p-2{padding:8px}}")
        );
        assert_eq!(rule("nope"), None);
    }

    #[test]
    fn media_groups_close_between_queries() {
        let css = stylesheet(&owned(&["dark:flex", "sm:flex", "sm:block"]));
        assert_eq!(
            css,
            concat!(
                r"@media (prefers-color-scheme:dark){.dark\:flex{display:flex}}",
                r"@media (min-width:640px){.sm\:flex{display:flex}.sm\:block{display:block}}",
            )
        );
    }

    #[test]
    fn registrations_precede_keyframes() {
        let css = stylesheet(&owned(&["animate-spin", "shadow"]));
        let prop = css.find("@property").unwrap();
        let kf = css.find("@keyframes").unwrap();
        assert!(prop < kf);
    }
}