Skip to main content

iris_cssom/
css_modules.rs

1//! CSS Modules: scoped class name hashing and rewriting.
2//!
3//! Transforms `.className` → `.className_hash` in both CSS and HTML
4//! to provide scoped styles without leaking to other components.
5
6use std::collections::HashMap;
7use std::hash::{DefaultHasher, Hash, Hasher};
8
9/// A CSS Modules processor for a single stylesheet/component pair.
10#[derive(Debug, Clone)]
11pub struct CssModules {
12    class_map: HashMap<String, String>,
13    rewritten_css: String,
14}
15
16impl CssModules {
17    /// Process CSS source with CSS Modules, producing hashed class names.
18    ///
19    /// * `css` — raw CSS source
20    /// * `scope` — scope name (e.g., component name), used as hash seed
21    pub fn process(css: &str, scope: &str) -> Self {
22        let mut class_map = HashMap::new();
23        let mut result = String::new();
24        let chars: Vec<char> = css.chars().collect();
25        let mut pos = 0;
26
27        while pos < chars.len() {
28            if chars[pos] == '.' {
29                pos += 1;
30                let mut class_name = String::new();
31                while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_' || chars[pos] == '-') {
32                    class_name.push(chars[pos]);
33                    pos += 1;
34                }
35                if !class_name.is_empty() {
36                    let hashed = class_map.entry(class_name.clone())
37                        .or_insert_with(|| hash_class_name(&class_name, scope));
38                    result.push('.');
39                    result.push_str(hashed);
40                    continue;
41                }
42                result.push('.');
43            } else {
44                result.push(chars[pos]);
45                pos += 1;
46            }
47        }
48
49        CssModules { class_map, rewritten_css: result }
50    }
51
52    pub fn map_class(&self, class: &str) -> Option<&str> {
53        self.class_map.get(class).map(|s| s.as_str())
54    }
55
56    pub fn rewrite_html(&self, html: &str) -> String {
57        let mut result = html.to_string();
58        for (original, hashed) in &self.class_map {
59            result = result.replace(&format!("class=\"{}\"", original), &format!("class=\"{}\"", hashed));
60            result = result.replace(&format!("class='{}'", original), &format!("class='{}'", hashed));
61        }
62        result
63    }
64
65    pub fn rewritten_css(&self) -> &str { &self.rewritten_css }
66    pub fn class_map(&self) -> &HashMap<String, String> { &self.class_map }
67}
68
69fn hash_class_name(name: &str, scope: &str) -> String {
70    let mut hasher = DefaultHasher::new();
71    scope.hash(&mut hasher);
72    name.hash(&mut hasher);
73    format!("{}_{}", name, to_base62(hasher.finish()))
74}
75
76fn to_base62(mut n: u64) -> String {
77    const CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
78    if n == 0 { return "0".to_string(); }
79    let mut result = Vec::new();
80    while n > 0 {
81        result.push(CHARS[(n % 62) as usize]);
82        n /= 62;
83    }
84    result.reverse();
85    String::from_utf8(result).unwrap_or_default()
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_basic() {
94        let m = CssModules::process(".btn { color: red; }", "Btn");
95        assert!(m.rewritten_css().contains(".btn_"));
96        assert!(m.rewritten_css().contains("color: red"));
97    }
98
99    #[test]
100    fn test_map_class() {
101        let m = CssModules::process(".header { }", "H");
102        assert!(m.map_class("header").unwrap().starts_with("header_"));
103    }
104
105    #[test]
106    fn test_rewrite_html() {
107        let m = CssModules::process(".card { }", "C");
108        let html = r#"<div class="card">x</div>"#;
109        assert!(m.rewrite_html(html).contains("card_"));
110    }
111
112    #[test]
113    fn test_scope_isolation() {
114        let a = CssModules::process(".btn { }", "A");
115        let b = CssModules::process(".btn { }", "B");
116        assert_ne!(a.map_class("btn"), b.map_class("btn"));
117    }
118}