AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/css_build.rs

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

//! Wire the atomic CSS engine ([`akurai_css`]) into the serve/dev pipeline.
//!
//! On `serve` startup the frontend's `.html`/`.js`/`.rs` source is scanned once for the
//! utility classes actually used and a single `utilities.css` is rendered and
//! cached in memory, served at `/utilities.css`. Under `akurai dev` the same
//! stylesheet is regenerated fresh on every request, so editing markup and
//! reloading immediately reflects newly used (or removed) utilities — no build
//! step, no file written to disk.
//!
//! This is **opt-in and harmless**: a project that never references a utility
//! class gets an empty (or tiny) `utilities.css`, and a project that never links
//! it behaves exactly as before. To use it, link it from your `<head>`:
//!
//! ```html
//! <link rel="stylesheet" href="/utilities.css" />
//! ```

use std::fs;
use std::path::Path;

/// File extensions whose contents are scanned for utility class tokens. HTML
/// templates carry `class="…"` attributes; JS may add classes dynamically
/// (`classList.add("flex")`); pure-Rust UIs (`akurai-ui`) keep them in string
/// literals. [`akurai_css::build_any`] picks candidate tokens out of any text.
const SCANNED_EXTS: &[&str] = &["html", "htm", "js", "mjs", "rs"];

/// Recursively collect the text of every scannable frontend source file under
/// `root`, in a stable (sorted) order so the generated stylesheet is
/// deterministic across runs.
pub fn collect_sources(root: &Path) -> Vec<String> {
    let mut files: Vec<std::path::PathBuf> = Vec::new();
    walk(root, &mut files);
    files.sort();
    files
        .iter()
        .filter_map(|p| fs::read_to_string(p).ok())
        .collect()
}

fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return; // missing/unreadable dir simply contributes nothing
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(meta) = entry.metadata() else { continue };
        if meta.is_dir() {
            walk(&path, out);
        } else if is_scanned(&path) {
            out.push(path);
        }
    }
}

fn is_scanned(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|ext| SCANNED_EXTS.iter().any(|s| ext.eq_ignore_ascii_case(s)))
        .unwrap_or(false)
}

/// Scan every frontend source under `root` and render the minimal `utilities.css`
/// containing only the recognised utility classes that appear. Returns an empty
/// string when nothing uses a utility class.
pub fn utilities_css(root: &Path) -> String {
    let sources = collect_sources(root);
    let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
    akurai_css::build_any(&refs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    /// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
    /// `target/` — never `/tmp`. Mirrors the helper in `collections_api.rs`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("cssbuild-test-tmp"),
        }
    }

    fn write(path: &Path, body: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    #[test]
    fn emits_only_used_utilities() {
        let dir = scratch_base().join("css_emit_used");
        let _ = fs::remove_dir_all(&dir);
        write(
            &dir.join("index.html"),
            r#"<div class="flex p-2 text-center handwritten-thing">hi</div>"#,
        );
        let css = utilities_css(&dir);
        assert!(css.contains(".flex{display:flex}"), "got: {css}");
        assert!(css.contains(".p-2{padding:8px}"), "got: {css}");
        assert!(
            css.contains(".text-center{text-align:center}"),
            "got: {css}"
        );
        // Unknown component class is ignored, not emitted as broken CSS.
        assert!(!css.contains("handwritten-thing"), "got: {css}");
    }

    #[test]
    fn scans_js_and_nested_dirs() {
        let dir = scratch_base().join("css_emit_nested");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("index.html"), r#"<div class="flex">x</div>"#);
        // A nested JS module that client-renders HTML with a `class` attribute
        // must still have its utilities picked up.
        write(
            &dir.join("ui/widgets.js"),
            r#"el.innerHTML = `<span class="font-bold">hi</span>`;"#,
        );
        let css = utilities_css(&dir);
        assert!(css.contains(".flex{display:flex}"), "got: {css}");
        assert!(css.contains(".font-bold{font-weight:700}"), "got: {css}");
    }

    #[test]
    fn scans_rust_ui_sources_and_variants() {
        // Pure-Rust UIs (akurai-ui) carry their classes in `.rs` string
        // literals, often with variants; both must reach the stylesheet.
        let dir = scratch_base().join("css_emit_rust");
        let _ = fs::remove_dir_all(&dir);
        write(
            &dir.join("ui/page.rs"),
            r#"div().class("grid hover:bg-panel data-[state=active]:text-fg")"#,
        );
        let css = utilities_css(&dir);
        assert!(css.contains(".grid{display:grid}"), "got: {css}");
        assert!(css.contains(r".hover\:bg-panel:hover"), "got: {css}");
        assert!(css.contains(r#"[data-state="active"]"#), "got: {css}");
    }

    #[test]
    fn no_utilities_yields_empty() {
        let dir = scratch_base().join("css_emit_empty");
        let _ = fs::remove_dir_all(&dir);
        write(
            &dir.join("index.html"),
            r#"<div class="custom-card">only hand-written classes</div>"#,
        );
        assert_eq!(utilities_css(&dir), "");
    }

    #[test]
    fn regeneration_reflects_source_change() {
        // The pure `utilities_css` function is the dev-regeneration seam: calling
        // it again after a frontend edit yields the updated stylesheet.
        let dir = scratch_base().join("css_emit_regen");
        let _ = fs::remove_dir_all(&dir);
        write(&dir.join("index.html"), r#"<div class="flex">x</div>"#);
        let before = utilities_css(&dir);
        assert!(before.contains(".flex{display:flex}"));
        assert!(!before.contains("text-center"));

        // Edit the source — add a new utility class.
        write(
            &dir.join("index.html"),
            r#"<div class="flex text-center">x</div>"#,
        );
        let after = utilities_css(&dir);
        assert!(after.contains(".flex{display:flex}"));
        assert!(
            after.contains(".text-center{text-align:center}"),
            "got: {after}"
        );
    }
}