iris-cssom 1.1.5

Iris CSS Object Model (CSSOM) implementation: CSS parsing, style computation, CSS Modules, and Web API
Documentation
//! CSS Modules: scoped class name hashing and rewriting.
//!
//! Transforms `.className` → `.className_hash` in both CSS and HTML
//! to provide scoped styles without leaking to other components.

use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};

/// A CSS Modules processor for a single stylesheet/component pair.
#[derive(Debug, Clone)]
pub struct CssModules {
    class_map: HashMap<String, String>,
    rewritten_css: String,
}

impl CssModules {
    /// Process CSS source with CSS Modules, producing hashed class names.
    ///
    /// * `css` — raw CSS source
    /// * `scope` — scope name (e.g., component name), used as hash seed
    pub fn process(css: &str, scope: &str) -> Self {
        let mut class_map = HashMap::new();
        let mut result = String::new();
        let chars: Vec<char> = css.chars().collect();
        let mut pos = 0;

        while pos < chars.len() {
            if chars[pos] == '.' {
                pos += 1;
                let mut class_name = String::new();
                while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_' || chars[pos] == '-') {
                    class_name.push(chars[pos]);
                    pos += 1;
                }
                if !class_name.is_empty() {
                    let hashed = class_map.entry(class_name.clone())
                        .or_insert_with(|| hash_class_name(&class_name, scope));
                    result.push('.');
                    result.push_str(hashed);
                    continue;
                }
                result.push('.');
            } else {
                result.push(chars[pos]);
                pos += 1;
            }
        }

        CssModules { class_map, rewritten_css: result }
    }

    pub fn map_class(&self, class: &str) -> Option<&str> {
        self.class_map.get(class).map(|s| s.as_str())
    }

    pub fn rewrite_html(&self, html: &str) -> String {
        let mut result = html.to_string();
        for (original, hashed) in &self.class_map {
            result = result.replace(&format!("class=\"{}\"", original), &format!("class=\"{}\"", hashed));
            result = result.replace(&format!("class='{}'", original), &format!("class='{}'", hashed));
        }
        result
    }

    pub fn rewritten_css(&self) -> &str { &self.rewritten_css }
    pub fn class_map(&self) -> &HashMap<String, String> { &self.class_map }
}

fn hash_class_name(name: &str, scope: &str) -> String {
    let mut hasher = DefaultHasher::new();
    scope.hash(&mut hasher);
    name.hash(&mut hasher);
    format!("{}_{}", name, to_base62(hasher.finish()))
}

fn to_base62(mut n: u64) -> String {
    const CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    if n == 0 { return "0".to_string(); }
    let mut result = Vec::new();
    while n > 0 {
        result.push(CHARS[(n % 62) as usize]);
        n /= 62;
    }
    result.reverse();
    String::from_utf8(result).unwrap_or_default()
}

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

    #[test]
    fn test_basic() {
        let m = CssModules::process(".btn { color: red; }", "Btn");
        assert!(m.rewritten_css().contains(".btn_"));
        assert!(m.rewritten_css().contains("color: red"));
    }

    #[test]
    fn test_map_class() {
        let m = CssModules::process(".header { }", "H");
        assert!(m.map_class("header").unwrap().starts_with("header_"));
    }

    #[test]
    fn test_rewrite_html() {
        let m = CssModules::process(".card { }", "C");
        let html = r#"<div class="card">x</div>"#;
        assert!(m.rewrite_html(html).contains("card_"));
    }

    #[test]
    fn test_scope_isolation() {
        let a = CssModules::process(".btn { }", "A");
        let b = CssModules::process(".btn { }", "B");
        assert_ne!(a.map_class("btn"), b.map_class("btn"));
    }
}