Menu
Public repository / 91bd2a37654909c5a3aef6ddd614991bd378ccb8
AkurAI-Framework / crates/cli/src/css_build.rs
Latest change e888012a38cfe0d4c47ddf4b3eecfba5ad6b8ac1 - Wire CSS engine + asset pipeline into serve/dev 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` 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")`), and the scanner matches bare tokens too.
const SCANNED_EXTS: &[&str] = &["html", "htm", "js", "mjs"];
/// 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(&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 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}"
);
}
}