use crate::assets::minify_css;
use crate::lua::async_io::{AsyncIOTask, runtime};
use crate::tracker::SharedTracker;
use lightningcss::bundler::{Bundler as CssBundler, FileProvider};
use lightningcss::printer::PrinterOptions;
use lightningcss::stylesheet::{MinifyOptions, ParserOptions};
use lightningcss::targets::Targets;
use mlua::{Lua, Result, Table, Value};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub fn create_module(lua: &Lua, project_root: &Path, tracker: SharedTracker) -> Result<Table> {
let css_module = lua.create_table()?;
let root = project_root.to_path_buf();
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let concat_fn = lua.create_function(
move |lua, (input, output_path, options): (Value, String, Option<Table>)| {
let opts = CssBundleOptions::from_lua(&options, lua)?;
let files = collect_files(&input, &root_clone, "css")?;
if files.is_empty() {
return Err(mlua::Error::external("No CSS files found"));
}
let output = resolve_output_path(&output_path, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let contents = read_files_async(&files, &tracker).await?;
let buffer: String = contents.join("\n");
let minified = if opts.minify {
tokio::task::spawn_blocking(move || minify_css(&buffer))
.await
.map_err(|e| e.to_string())??
} else {
buffer
};
let output_content = if opts.purge {
tracker.merge_thread_locals();
let html_content = collect_html_from_tracker(&tracker);
if html_content.is_empty() {
log::warn!("CSS purge: No HTML files found in tracker, keeping all CSS");
minified
} else {
let used = extract_used_selectors(&html_content);
let safelist = compile_safelist(&opts.safelist);
let original_len = minified.len();
let purged = purge_css(&minified, &used, &safelist);
let purged_len = purged.len();
if original_len > purged_len {
let saved = original_len - purged_len;
let percent = (saved as f64 / original_len as f64) * 100.0;
log::info!(
"CSS purge: removed {} bytes ({:.1}% reduction)",
saved,
percent
);
}
purged
}
} else {
minified
};
write_output_async(&output, &output_content, &tracker).await
});
Ok(AsyncIOTask::new(handle))
},
)?;
css_module.set("concat", concat_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let bundle_fn = lua.create_function(
move |lua, (input, output_path, options): (Value, String, Option<Table>)| {
let opts = CssBundleOptions::from_lua(&options, lua)?;
let entries = collect_entries(&input, &root_clone)?;
if entries.is_empty() {
return Err(mlua::Error::external("No CSS entry files found"));
}
let output = resolve_output_path(&output_path, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
bundle_css_with_lightningcss(&entries, &output, &opts, &tracker).await
});
Ok(AsyncIOTask::new(handle))
},
)?;
css_module.set("bundle", bundle_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let bundle_many_fn = lua.create_function(
move |lua, (input, output_dir, options): (Value, String, Option<Table>)| {
let opts = CssBundleOptions::from_lua(&options, lua)?;
let entries = collect_entries(&input, &root_clone)?;
if entries.is_empty() {
return Err(mlua::Error::external("No CSS entry files found"));
}
let output = resolve_output_path(&output_dir, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
bundle_many_css_with_lightningcss(&entries, &output, &opts, &tracker).await
});
Ok(AsyncIOTask::new(handle))
},
)?;
css_module.set("bundle_many", bundle_many_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let purge_fn =
lua.create_function(move |_lua, (css_path, options): (String, Option<Table>)| {
let safelist: Vec<String> = options
.as_ref()
.and_then(|t| {
t.get::<Table>("safelist").ok().map(|tbl| {
tbl.pairs::<i64, String>()
.filter_map(|r| r.ok().map(|(_, v)| v))
.collect()
})
})
.unwrap_or_default();
let css_file = resolve_output_path(&css_path, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let css_content = tokio::fs::read_to_string(&css_file)
.await
.map_err(|e| format!("Failed to read CSS file: {}", e))?;
tracker.merge_thread_locals();
let content = collect_content_for_purge(&tracker);
if content.is_empty() {
log::warn!("CSS purge: No HTML/JS files found in tracker, keeping all CSS");
return Ok(());
}
let used = extract_used_selectors(&content);
let safelist_regex = compile_safelist(&safelist);
let original_len = css_content.len();
let purged = purge_css(&css_content, &used, &safelist_regex);
let purged_len = purged.len();
if original_len > purged_len {
let saved = original_len - purged_len;
let percent = (saved as f64 / original_len as f64) * 100.0;
log::info!(
"CSS purge: removed {} bytes ({:.1}% reduction)",
saved,
percent
);
tokio::fs::write(&css_file, &purged)
.await
.map_err(|e| format!("Failed to write purged CSS: {}", e))?;
let canonical = css_file.canonicalize().unwrap_or(css_file);
tracker.record_write_async(canonical, purged.as_bytes());
}
Ok(())
});
Ok(AsyncIOTask::new(handle))
})?;
css_module.set("purge", purge_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker.clone();
let critical_fn = lua.create_function(
move |_lua, (html_content, css_path, options): (String, String, Option<Table>)| {
let minify = options
.as_ref()
.and_then(|t| t.get::<bool>("minify").ok())
.unwrap_or(false);
let safelist: Vec<String> = options
.as_ref()
.and_then(|t| {
t.get::<Table>("safelist").ok().map(|tbl| {
tbl.pairs::<i64, String>()
.filter_map(|r| r.ok().map(|(_, v)| v))
.collect()
})
})
.unwrap_or_default();
let css_file = resolve_output_path(&css_path, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let css_content = tokio::fs::read_to_string(&css_file)
.await
.map_err(|e| format!("Failed to read CSS file: {}", e))?;
if let Ok(canonical) = css_file.canonicalize() {
tracker.record_read(canonical, css_content.as_bytes());
}
let critical_css = tokio::task::spawn_blocking(move || {
let used = extract_used_selectors(&html_content);
let safelist_regex = compile_safelist(&safelist);
let critical = purge_css(&css_content, &used, &safelist_regex);
if minify {
minify_css(&critical).unwrap_or(critical)
} else {
critical
}
})
.await
.map_err(|e| format!("Task failed: {}", e))?;
Ok(critical_css)
});
Ok(AsyncIOTask::from_string_handle(handle))
},
)?;
css_module.set("critical", critical_fn)?;
let root_clone = root.clone();
let tracker_clone = tracker;
let inline_critical_fn = lua.create_function(
move |_lua, (html_path, css_path, options): (String, String, Option<Table>)| {
let minify = options
.as_ref()
.and_then(|t| t.get::<bool>("minify").ok())
.unwrap_or(false);
let safelist: Vec<String> = options
.as_ref()
.and_then(|t| {
t.get::<Table>("safelist").ok().map(|tbl| {
tbl.pairs::<i64, String>()
.filter_map(|r| r.ok().map(|(_, v)| v))
.collect()
})
})
.unwrap_or_default();
let css_href = options
.as_ref()
.and_then(|t| t.get::<String>("css_href").ok());
let html_file = resolve_output_path(&html_path, &root_clone);
let css_file = resolve_output_path(&css_path, &root_clone);
let tracker = tracker_clone.clone();
let handle = runtime().spawn(async move {
let (html_content, css_content) = tokio::try_join!(
tokio::fs::read_to_string(&html_file),
tokio::fs::read_to_string(&css_file)
)
.map_err(|e| format!("Failed to read files: {}", e))?;
if let Ok(canonical) = html_file.canonicalize() {
tracker.record_read(canonical, html_content.as_bytes());
}
if let Ok(canonical) = css_file.canonicalize() {
tracker.record_read(canonical, css_content.as_bytes());
}
let html_clone = html_content.clone();
let result_html = tokio::task::spawn_blocking(move || {
let used = extract_used_selectors(&html_clone);
let safelist_regex = compile_safelist(&safelist);
let critical = purge_css(&css_content, &used, &safelist_regex);
let critical = if minify {
minify_css(&critical).unwrap_or(critical)
} else {
critical
};
inline_critical_css(&html_content, &critical, css_href.as_deref())
})
.await
.map_err(|e| format!("Task failed: {}", e))?;
tokio::fs::write(&html_file, &result_html)
.await
.map_err(|e| format!("Failed to write HTML: {}", e))?;
let canonical = html_file.canonicalize().unwrap_or(html_file);
tracker.record_write_async(canonical, result_html.as_bytes());
Ok(())
});
Ok(AsyncIOTask::new(handle))
},
)?;
css_module.set("inline_critical", inline_critical_fn)?;
Ok(css_module)
}
fn inline_critical_css(html: &str, critical_css: &str, css_href: Option<&str>) -> String {
let html = remove_existing_critical_css(html);
let mut critical_block = String::new();
critical_block.push_str("<style id=\"rs-critical-css\">");
critical_block.push_str(critical_css);
critical_block.push_str("</style>");
if let Some(href) = css_href {
critical_block.push_str(&format!(
r#"<link id="rs-critical-async" rel=stylesheet href="{}" media=print onload="this.media='all'">"#,
href
));
critical_block.push_str(&format!(
r#"<noscript id="rs-critical-noscript"><link rel=stylesheet href="{}"></noscript>"#,
href
));
}
if let Some(pos) = html.find("</head>") {
let mut result = String::with_capacity(html.len() + critical_block.len());
result.push_str(&html[..pos]);
result.push_str(&critical_block);
result.push_str(&html[pos..]);
return result;
}
if let Some(pos) = html.find("<body") {
let mut result = String::with_capacity(html.len() + critical_block.len());
result.push_str(&html[..pos]);
result.push_str(&critical_block);
result.push_str(&html[pos..]);
return result;
}
if let Some(last_link_end) = find_last_stylesheet_end(&html) {
let mut result = String::with_capacity(html.len() + critical_block.len());
result.push_str(&html[..last_link_end]);
result.push_str(&critical_block);
result.push_str(&html[last_link_end..]);
return result;
}
html.to_string()
}
fn remove_existing_critical_css(html: &str) -> String {
let mut result = html.to_string();
result = remove_element_by_id(&result, "rs-critical-css", "style", "</style>");
result = remove_element_by_id(&result, "critical-css", "style", "</style>");
result = remove_self_closing_by_id(&result, "rs-critical-async");
result = remove_element_by_id(&result, "rs-critical-noscript", "noscript", "</noscript>");
result
}
fn remove_element_by_id(html: &str, id: &str, _tag: &str, close_tag: &str) -> String {
let id_pattern = format!("id=\"{}\"", id);
if let Some(id_pos) = html.find(&id_pattern) {
if let Some(tag_start) = html[..id_pos].rfind('<') {
if let Some(close_rel) = html[tag_start..].find(close_tag) {
let end = tag_start + close_rel + close_tag.len();
return format!("{}{}", &html[..tag_start], &html[end..]);
}
}
}
html.to_string()
}
fn remove_self_closing_by_id(html: &str, id: &str) -> String {
let id_pattern = format!("id=\"{}\"", id);
if let Some(id_pos) = html.find(&id_pattern) {
if let Some(tag_start) = html[..id_pos].rfind('<') {
if let Some(tag_end_rel) = html[tag_start..].find('>') {
let end = tag_start + tag_end_rel + 1;
return format!("{}{}", &html[..tag_start], &html[end..]);
}
}
}
html.to_string()
}
fn find_last_stylesheet_end(html: &str) -> Option<usize> {
let mut last_end = None;
let mut search_start = 0;
while let Some(link_start) = html[search_start..].find("<link") {
let abs_start = search_start + link_start;
if let Some(link_end_rel) = html[abs_start..].find('>') {
let abs_end = abs_start + link_end_rel + 1;
let link_tag = &html[abs_start..abs_end];
if link_tag.contains("stylesheet") {
last_end = Some(abs_end);
}
search_start = abs_end;
} else {
break;
}
}
last_end
}
#[derive(Debug, Clone)]
struct CssBundleOptions {
minify: bool,
sourcemap: bool,
nesting: bool,
purge: bool,
safelist: Vec<String>,
}
impl Default for CssBundleOptions {
fn default() -> Self {
Self {
minify: false,
sourcemap: false,
nesting: true,
purge: false,
safelist: Vec::new(),
}
}
}
impl CssBundleOptions {
fn from_lua(options: &Option<Table>, _lua: &Lua) -> mlua::Result<Self> {
let mut opts = Self::default();
if let Some(t) = options {
if let Ok(minify) = t.get::<bool>("minify") {
opts.minify = minify;
}
if let Ok(sourcemap) = t.get::<bool>("sourcemap") {
opts.sourcemap = sourcemap;
}
if let Ok(nesting) = t.get::<bool>("nesting") {
opts.nesting = nesting;
}
if let Ok(purge) = t.get::<bool>("purge") {
opts.purge = purge;
}
if let Ok(safelist) = t.get::<Table>("safelist") {
for (_, pattern) in safelist.pairs::<i64, String>().flatten() {
opts.safelist.push(pattern);
}
}
}
Ok(opts)
}
}
fn collect_entries(input: &Value, root: &Path) -> mlua::Result<Vec<PathBuf>> {
match input {
Value::Table(table) => {
let mut files = Vec::new();
for pair in table.pairs::<i64, String>() {
let (_, path_str) = pair
.map_err(|e| mlua::Error::external(format!("Invalid path in array: {}", e)))?;
let path = if Path::new(&path_str).is_absolute() {
PathBuf::from(&path_str)
} else {
root.join(&path_str)
};
if path.exists() && path.is_file() {
files.push(path);
} else {
return Err(mlua::Error::external(format!(
"CSS entry file not found: {}",
path_str
)));
}
}
Ok(files)
}
Value::String(pattern_str) => {
let pattern = pattern_str
.to_str()
.map_err(|e| mlua::Error::external(format!("Invalid pattern: {}", e)))?
.to_string();
let path = if Path::new(&pattern).is_absolute() {
PathBuf::from(&pattern)
} else {
root.join(&pattern)
};
if path.exists() && path.is_file() {
return Ok(vec![path]);
}
let glob_pattern = if Path::new(&pattern).is_absolute() {
pattern
} else {
root.join(&pattern).to_string_lossy().to_string()
};
let mut files: Vec<PathBuf> = glob::glob(&glob_pattern)
.map_err(|e| mlua::Error::external(format!("Invalid glob: {}", e)))?
.filter_map(|e| e.ok())
.filter(|p| p.is_file())
.collect();
files.sort();
Ok(files)
}
_ => Err(mlua::Error::external(
"css.bundle: first argument must be path, glob pattern, or array of paths",
)),
}
}
async fn bundle_css_with_lightningcss(
entries: &[PathBuf],
output: &Path,
opts: &CssBundleOptions,
tracker: &SharedTracker,
) -> std::result::Result<(), String> {
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create output directory: {}", e))?;
}
let opts = opts.clone();
let entries = entries.to_vec();
let output = output.to_path_buf();
let tracker = tracker.clone();
tokio::task::spawn_blocking(move || {
let fs = FileProvider::new();
let mut combined_css = String::new();
for entry in &entries {
let parser_options = ParserOptions::default();
let mut bundler = CssBundler::new(&fs, None, parser_options);
let stylesheet = bundler
.bundle(entry)
.map_err(|e| format!("Failed to bundle {:?}: {}", entry, e))?;
let mut stylesheet = stylesheet;
if opts.minify {
let targets = Targets::default();
stylesheet
.minify(MinifyOptions {
targets,
..Default::default()
})
.map_err(|e| format!("Failed to minify: {:?}", e))?;
}
let printer_options = PrinterOptions {
minify: opts.minify,
..Default::default()
};
let result = stylesheet
.to_css(printer_options)
.map_err(|e| format!("Failed to serialize CSS: {:?}", e))?;
combined_css.push_str(&result.code);
combined_css.push('\n');
}
let final_css = if opts.purge {
tracker.merge_thread_locals();
let html_content = collect_html_from_tracker(&tracker);
if html_content.is_empty() {
log::warn!("CSS purge: No HTML files found in tracker, keeping all CSS");
combined_css
} else {
let used = extract_used_selectors(&html_content);
let safelist = compile_safelist(&opts.safelist);
let original_len = combined_css.len();
let purged = purge_css(&combined_css, &used, &safelist);
let purged_len = purged.len();
if original_len > purged_len {
let saved = original_len - purged_len;
let percent = (saved as f64 / original_len as f64) * 100.0;
log::info!(
"CSS purge: removed {} bytes ({:.1}% reduction)",
saved,
percent
);
}
purged
}
} else {
combined_css
};
std::fs::write(&output, &final_css)
.map_err(|e| format!("Failed to write output: {}", e))?;
let canonical = output.canonicalize().unwrap_or_else(|_| output.clone());
tracker.record_write(canonical, final_css.as_bytes());
Ok(())
})
.await
.map_err(|e| format!("Task failed: {}", e))?
}
async fn bundle_many_css_with_lightningcss(
entries: &[PathBuf],
output_dir: &Path,
opts: &CssBundleOptions,
tracker: &SharedTracker,
) -> std::result::Result<(), String> {
tokio::fs::create_dir_all(output_dir)
.await
.map_err(|e| format!("Failed to create output directory: {}", e))?;
let opts = opts.clone();
let entries = entries.to_vec();
let output_dir = output_dir.to_path_buf();
let tracker = tracker.clone();
tokio::task::spawn_blocking(move || {
let fs = FileProvider::new();
let (used, safelist) = if opts.purge {
tracker.merge_thread_locals();
let html_content = collect_html_from_tracker(&tracker);
if html_content.is_empty() {
log::warn!("CSS purge: No HTML files found in tracker, keeping all CSS");
(None, Vec::new())
} else {
(
Some(extract_used_selectors(&html_content)),
compile_safelist(&opts.safelist),
)
}
} else {
(None, Vec::new())
};
for entry in &entries {
let parser_options = ParserOptions::default();
let mut bundler = CssBundler::new(&fs, None, parser_options);
let stylesheet = bundler
.bundle(entry)
.map_err(|e| format!("Failed to bundle {:?}: {}", entry, e))?;
let mut stylesheet = stylesheet;
if opts.minify {
let targets = Targets::default();
stylesheet
.minify(MinifyOptions {
targets,
..Default::default()
})
.map_err(|e| format!("Failed to minify: {:?}", e))?;
}
let printer_options = PrinterOptions {
minify: opts.minify,
..Default::default()
};
let result = stylesheet
.to_css(printer_options)
.map_err(|e| format!("Failed to serialize CSS: {:?}", e))?;
let final_css = if let Some(ref used) = used {
purge_css(&result.code, used, &safelist)
} else {
result.code
};
let filename = entry
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output.css");
let output_path = output_dir.join(filename);
std::fs::write(&output_path, &final_css)
.map_err(|e| format!("Failed to write output: {}", e))?;
let canonical = output_path.canonicalize().unwrap_or(output_path);
tracker.record_write(canonical, final_css.as_bytes());
}
Ok(())
})
.await
.map_err(|e| format!("Task failed: {}", e))?
}
fn collect_files(input: &Value, root: &Path, ext: &str) -> mlua::Result<Vec<PathBuf>> {
match input {
Value::Table(table) => {
let mut files = Vec::new();
for pair in table.pairs::<i64, String>() {
let (_, path_str) = pair
.map_err(|e| mlua::Error::external(format!("Invalid path in array: {}", e)))?;
let path = if Path::new(&path_str).is_absolute() {
PathBuf::from(&path_str)
} else {
root.join(&path_str)
};
if path.exists() && path.is_file() {
files.push(path);
} else {
return Err(mlua::Error::external(format!(
"{} file not found: {}",
ext.to_uppercase(),
path_str
)));
}
}
Ok(files)
}
Value::String(pattern_str) => {
let pattern = pattern_str
.to_str()
.map_err(|e| mlua::Error::external(format!("Invalid pattern: {}", e)))?
.to_string();
let glob_pattern = if Path::new(&pattern).is_absolute() {
pattern.clone()
} else {
root.join(&pattern).to_string_lossy().to_string()
};
let mut files: Vec<PathBuf> = glob::glob(&glob_pattern)
.map_err(|e| mlua::Error::external(format!("Invalid glob: {}", e)))?
.filter_map(|e| e.ok())
.filter(|p| p.is_file())
.collect();
files.sort();
Ok(files)
}
_ => Err(mlua::Error::external(
"css.concat: first argument must be glob pattern or array of paths",
)),
}
}
fn resolve_output_path(output_path: &str, root: &Path) -> PathBuf {
if Path::new(output_path).is_absolute() {
PathBuf::from(output_path)
} else {
root.join(output_path)
}
}
async fn read_files_async(
files: &[PathBuf],
tracker: &SharedTracker,
) -> std::result::Result<Vec<String>, String> {
let futures: Vec<_> = files
.iter()
.map(|path| {
let path = path.clone();
let tracker = tracker.clone();
async move {
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| format!("Failed to read {:?}: {}", path, e))?;
let canonical = path.canonicalize().unwrap_or(path);
tracker.record_read(canonical, content.as_bytes());
Ok::<_, String>(content)
}
})
.collect();
let results = futures::future::join_all(futures).await;
results.into_iter().collect()
}
async fn write_output_async(
output: &Path,
content: &str,
tracker: &SharedTracker,
) -> std::result::Result<(), String> {
if let Some(parent) = output.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
tokio::fs::write(output, content)
.await
.map_err(|e| format!("Failed to write: {}", e))?;
let canonical = output
.canonicalize()
.unwrap_or_else(|_| output.to_path_buf());
tracker.record_write(canonical, content.as_bytes());
Ok(())
}
static ATTR_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*["']([^"']+)["']"#).unwrap());
static DATA_ATTR_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(data-[a-zA-Z0-9_-]+)"#).unwrap());
static SELECTOR_CLASS_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\.([a-zA-Z_-][\w-]*)").unwrap());
static SELECTOR_ID_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"#([a-zA-Z_-][\w-]*)").unwrap());
static SELECTOR_TAG_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[\s>+~,])([a-zA-Z][a-zA-Z0-9-]*)").unwrap());
static CSS_ATTR_SELECTOR_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"\[([a-zA-Z][a-zA-Z0-9_-]*)(?:[~|^$*]?=["']?([^"'\]]+)["']?)?\]"#).unwrap()
});
static WORD_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[a-zA-Z_][a-zA-Z0-9_-]*").unwrap());
fn extract_used_selectors(content: &str) -> UsedSelectors {
let mut selectors = UsedSelectors::default();
for cap in WORD_REGEX.captures_iter(content) {
if let Some(word) = cap.get(0) {
let w = word.as_str();
selectors.words.insert(w.to_string());
selectors.words.insert(w.to_lowercase());
for part in w.split('-') {
if !part.is_empty() {
selectors.words.insert(part.to_string());
selectors.words.insert(part.to_lowercase());
}
}
for part in w.split('_') {
if !part.is_empty() {
selectors.words.insert(part.to_string());
selectors.words.insert(part.to_lowercase());
}
}
}
}
for cap in ATTR_REGEX.captures_iter(content) {
if let Some(attr_name) = cap.get(1) {
selectors
.attributes
.insert(attr_name.as_str().to_lowercase());
}
}
for cap in DATA_ATTR_REGEX.captures_iter(content) {
if let Some(attr) = cap.get(1) {
selectors.attributes.insert(attr.as_str().to_lowercase());
}
}
log::debug!(
"CSS purge: extracted {} unique words, {} attributes",
selectors.words.len(),
selectors.attributes.len()
);
selectors
}
#[derive(Debug, Default)]
struct UsedSelectors {
words: HashSet<String>,
attributes: HashSet<String>,
}
impl UsedSelectors {
fn is_selector_used(&self, selector: &str, safelist: &[Regex]) -> bool {
for pattern in safelist {
if pattern.is_match(selector) {
return true;
}
}
if selector.trim().starts_with('@') {
return true;
}
let trimmed = selector.trim();
if trimmed == "*"
|| trimmed == ":root"
|| trimmed.starts_with(":root")
|| trimmed == "html"
|| trimmed == "body"
{
return true;
}
for cap in CSS_ATTR_SELECTOR_REGEX.captures_iter(selector) {
if let Some(attr_name) = cap.get(1) {
let attr = attr_name.as_str().to_lowercase();
if self.attributes.contains(&attr) {
return true;
}
if matches!(
attr.as_str(),
"type"
| "name"
| "value"
| "checked"
| "disabled"
| "open"
| "hidden"
| "readonly"
| "required"
| "selected"
| "href"
| "src"
| "role"
| "aria-expanded"
| "aria-hidden"
| "aria-selected"
| "data-"
) || attr.starts_with("data-")
|| attr.starts_with("aria-")
{
return true;
}
}
}
let mut has_any_selector = false;
for cap in SELECTOR_CLASS_REGEX.captures_iter(selector) {
has_any_selector = true;
if let Some(class) = cap.get(1) {
let class_str = class.as_str();
if self.words.contains(class_str) {
return true;
}
for part in class_str.split('-') {
if !part.is_empty() && self.words.contains(part) {
return true;
}
}
for part in class_str.split('_') {
if !part.is_empty() && self.words.contains(part) {
return true;
}
}
}
}
for cap in SELECTOR_ID_REGEX.captures_iter(selector) {
has_any_selector = true;
if let Some(id) = cap.get(1) {
let id_str = id.as_str();
if self.words.contains(id_str) {
return true;
}
for part in id_str.split('-') {
if !part.is_empty() && self.words.contains(part) {
return true;
}
}
for part in id_str.split('_') {
if !part.is_empty() && self.words.contains(part) {
return true;
}
}
}
}
for cap in SELECTOR_TAG_REGEX.captures_iter(selector) {
has_any_selector = true;
if let Some(tag) = cap.get(1) {
let tag_lower = tag.as_str().to_lowercase();
if is_common_html_tag(&tag_lower) {
return true;
}
if self.words.contains(&tag_lower) {
return true;
}
}
}
!has_any_selector
}
}
fn purge_css(css: &str, used: &UsedSelectors, safelist: &[Regex]) -> String {
let mut result = String::with_capacity(css.len());
let chars: Vec<char> = css.chars().collect();
let len = chars.len();
let mut i = 0;
let mut current_selector = String::new();
let mut current_body = String::new();
let mut depth = 0;
while i < len {
let c = chars[i];
if c == '/' && i + 1 < len && chars[i + 1] == '*' {
let comment_start = i;
i += 2;
while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2; let comment: String = chars[comment_start..i.min(len)].iter().collect();
if depth == 0 {
current_selector.push_str(&comment);
} else {
current_body.push_str(&comment);
}
continue;
}
if c == '"' || c == '\'' {
let quote = c;
let string_start = i;
i += 1;
while i < len {
if chars[i] == '\\' && i + 1 < len {
i += 2; } else if chars[i] == quote {
i += 1;
break;
} else {
i += 1;
}
}
let string_content: String = chars[string_start..i].iter().collect();
if depth == 0 {
current_selector.push_str(&string_content);
} else {
current_body.push_str(&string_content);
}
continue;
}
match c {
'{' => {
if depth == 0 {
depth = 1;
} else {
depth += 1;
current_body.push(c);
}
}
'}' => {
if depth <= 1 {
depth = 0;
let selector = current_selector.trim();
if selector.is_empty() {
} else if selector.starts_with('@') {
if selector.contains("@media")
|| selector.contains("@supports")
|| selector.contains("@layer")
|| selector.contains("@container")
{
let purged_body = purge_css(¤t_body, used, safelist);
if !purged_body.trim().is_empty() {
result.push_str(selector);
result.push('{');
result.push_str(&purged_body);
result.push('}');
}
} else {
result.push_str(selector);
result.push('{');
result.push_str(¤t_body);
result.push('}');
}
} else {
let selectors: Vec<&str> = selector.split(',').collect();
let used_selectors: Vec<&str> = selectors
.iter()
.filter(|s| used.is_selector_used(s, safelist))
.copied()
.collect();
for s in &selectors {
if !used.is_selector_used(s, safelist) {
log::debug!("CSS purge: removing selector '{}'", s.trim());
}
}
if !used_selectors.is_empty() {
result.push_str(&used_selectors.join(","));
result.push('{');
result.push_str(¤t_body);
result.push('}');
}
}
current_selector.clear();
current_body.clear();
} else {
depth -= 1;
current_body.push(c);
}
}
_ => {
if depth == 0 {
current_selector.push(c);
} else {
current_body.push(c);
}
}
}
i += 1;
}
result
}
fn collect_html_from_tracker(tracker: &SharedTracker) -> String {
let writes = tracker.get_writes();
let mut html_content = String::new();
for (path, _) in writes {
if path.extension().and_then(|e| e.to_str()) == Some("html")
&& let Ok(content) = std::fs::read_to_string(&path)
{
html_content.push_str(&content);
html_content.push('\n');
}
}
html_content
}
fn collect_content_for_purge(tracker: &SharedTracker) -> String {
let writes = tracker.get_writes();
let mut content = String::new();
let mut html_count = 0;
let mut js_count = 0;
for path in writes.keys() {
let ext = path.extension().and_then(|e| e.to_str());
if ext == Some("html") {
html_count += 1;
if let Ok(file_content) = std::fs::read_to_string(path) {
content.push_str(&file_content);
content.push('\n');
}
} else if ext == Some("js") {
js_count += 1;
if let Ok(file_content) = std::fs::read_to_string(path) {
content.push_str(&file_content);
content.push('\n');
}
}
}
log::debug!(
"CSS purge: collected content from {} HTML files, {} JS files ({} bytes total)",
html_count,
js_count,
content.len()
);
content
}
fn compile_safelist(patterns: &[String]) -> Vec<Regex> {
patterns.iter().filter_map(|p| Regex::new(p).ok()).collect()
}
fn is_common_html_tag(tag: &str) -> bool {
matches!(
tag,
"html" | "head" | "body" | "main" | "header" | "footer" | "nav" | "aside" |
"article" | "section" | "div" | "span" |
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" |
"p" | "a" | "strong" | "b" | "em" | "i" | "u" | "s" | "del" | "ins" |
"mark" | "small" | "sub" | "sup" | "abbr" | "cite" | "code" | "kbd" |
"samp" | "var" | "time" | "q" | "blockquote" | "pre" | "address" |
"ul" | "ol" | "li" | "dl" | "dt" | "dd" |
"table" | "thead" | "tbody" | "tfoot" | "tr" | "th" | "td" | "caption" |
"colgroup" | "col" |
"form" | "fieldset" | "legend" | "input" | "button" | "select" |
"option" | "optgroup" | "textarea" | "label" | "output" | "datalist" |
"img" | "picture" | "source" | "video" | "audio" | "track" |
"figure" | "figcaption" | "canvas" | "svg" | "iframe" | "embed" | "object" |
"details" | "summary" | "dialog" | "menu" |
"hr" | "br" | "wbr" | "template" | "slot" | "noscript"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_css_minify() {
let input = r#"
.class {
color: red;
margin: 0;
}
"#;
let result = minify_css(input).unwrap();
assert!(!result.contains('\n') || result.len() < input.len());
}
#[test]
fn test_extract_selectors_from_html() {
let html = r#"
<div class="container main-content" id="app">
<span class="text-red">Hello</span>
<button class="btn btn-primary">Click</button>
</div>
"#;
let selectors = extract_used_selectors(html);
assert!(selectors.words.contains("container"));
assert!(selectors.words.contains("main")); assert!(selectors.words.contains("content")); assert!(selectors.words.contains("text")); assert!(selectors.words.contains("red")); assert!(selectors.words.contains("btn"));
assert!(selectors.words.contains("primary")); assert!(selectors.words.contains("app"));
assert!(selectors.words.contains("div"));
assert!(selectors.words.contains("span"));
assert!(selectors.words.contains("button"));
}
#[test]
fn test_purge_css_removes_unused() {
let css = ".used { color: red; } .unused { color: blue; }";
let html = r#"<div class="used">Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains(".used"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_purge_css_keeps_root_html_body() {
let css = ":root { --color: red; } html { font-size: 16px; } body { margin: 0; } .unused { color: blue; }";
let html = r#"<div>Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains(":root"));
assert!(result.contains("html"));
assert!(result.contains("body"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_purge_css_handles_media_queries() {
let css = "@media (min-width: 768px) { .used { color: red; } .unused { color: blue; } }";
let html = r#"<div class="used">Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains("@media"));
assert!(result.contains(".used"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_purge_css_keeps_keyframes() {
let css = "@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .unused { color: blue; }";
let html = r#"<div>Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains("@keyframes"));
assert!(result.contains("fadeIn"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_purge_css_safelist() {
let css = ".dynamic-class { color: red; } .unused { color: blue; }";
let html = r#"<div>Hello</div>"#;
let used = extract_used_selectors(html);
let safelist = compile_safelist(&["dynamic-.*".to_string()]);
let result = purge_css(css, &used, &safelist);
assert!(result.contains(".dynamic-class"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_purge_css_compound_selectors() {
let css = "div.container { color: red; } span.unused { color: blue; } .totally-unused { color: green; }";
let html = r#"<div class="container">Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains("div.container"));
assert!(result.contains("span.unused")); assert!(!result.contains(".totally-unused")); }
#[test]
fn test_purge_css_comma_selectors() {
let css = ".used, .also-used, .unused { color: red; }";
let html = r#"<div class="used also-used">Hello</div>"#;
let used = extract_used_selectors(html);
let result = purge_css(css, &used, &[]);
assert!(result.contains(".used"));
assert!(result.contains(".also-used"));
assert!(!result.contains(".unused"));
}
#[test]
fn test_extract_selectors_from_js() {
let js = r#"
element.classList.add('active');
element.classList.remove('hidden');
element.classList.toggle('visible');
element.className = 'container main';
element.className += 'extra';
$(el).addClass('jquery-class');
document.querySelector('.query-class');
document.querySelectorAll('#query-id, .another-class');
"#;
let selectors = extract_used_selectors(js);
assert!(selectors.words.contains("active"));
assert!(selectors.words.contains("hidden"));
assert!(selectors.words.contains("visible"));
assert!(selectors.words.contains("container"));
assert!(selectors.words.contains("main"));
assert!(selectors.words.contains("extra"));
assert!(selectors.words.contains("jquery")); assert!(selectors.words.contains("class")); assert!(selectors.words.contains("query")); assert!(selectors.words.contains("another")); }
#[test]
fn test_purge_with_js_classes() {
let css = ".active { color: green; } .inactive { color: gray; } .hidden { display: none; }";
let content = r#"
<div class="container">Hello</div>
<script>element.classList.add('active'); element.classList.toggle('hidden');</script>
"#;
let used = extract_used_selectors(content);
let result = purge_css(css, &used, &[]);
assert!(result.contains(".active"));
assert!(result.contains(".hidden"));
assert!(!result.contains(".inactive"));
}
#[test]
fn test_inline_critical_css_basic() {
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<div class="container">Hello</div>
</body>
</html>"#;
let critical_css = ".container { color: red; }";
let result = inline_critical_css(html, critical_css, None);
assert!(result.contains("<style id=\"rs-critical-css\">"));
assert!(result.contains(".container { color: red; }"));
assert!(result.contains("</style></head>"));
}
#[test]
fn test_inline_critical_css_with_async_loading() {
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<div>Hello</div>
</body>
</html>"#;
let critical_css = "body { margin: 0; }";
let result = inline_critical_css(html, critical_css, Some("/styles/main.css"));
assert!(result.contains("<style id=\"rs-critical-css\">"));
assert!(result.contains("body { margin: 0; }"));
assert!(result.contains(r#"id="rs-critical-async""#));
assert!(result.contains(r#"media=print onload="this.media='all'""#));
assert!(result.contains(r#"href="/styles/main.css""#));
assert!(result.contains(r#"<noscript id="rs-critical-noscript">"#));
}
#[test]
fn test_inline_critical_css_minified_with_body() {
let html = r#"<!doctype html><html lang=en><meta charset=UTF-8><link href=/styles/main.css rel=stylesheet><body><div>Hello</div>"#;
let critical_css = "body{margin:0}";
let result = inline_critical_css(html, critical_css, Some("/styles/main.css"));
assert!(result.contains("<style id=\"rs-critical-css\">"));
assert!(result.contains("body{margin:0}"));
assert!(result.contains(r#"</noscript><body>"#));
}
#[test]
fn test_inline_critical_css_minified_no_body() {
let html = r#"<!doctype html><html lang=en><meta charset=UTF-8><link href=/styles/main.css rel=stylesheet><div>Hello</div>"#;
let critical_css = "body{margin:0}";
let result = inline_critical_css(html, critical_css, Some("/styles/main.css"));
assert!(result.contains("<style id=\"rs-critical-css\">"));
assert!(result.contains("body{margin:0}"));
assert!(result.contains(r#"rel=stylesheet><style id="rs-critical-css">"#));
}
#[test]
fn test_inline_critical_css_no_insertion_point() {
let html = r#"<div>No head, body, or stylesheet</div>"#;
let critical_css = ".test { color: red; }";
let result = inline_critical_css(html, critical_css, None);
assert_eq!(result, html);
}
#[test]
fn test_inline_critical_css_replaces_existing() {
let html = r#"<!doctype html><html><link href=/styles/main.css rel=stylesheet><style id="rs-critical-css">OLD CSS</style><link id="rs-critical-async" rel=stylesheet href="/styles/main.abc123.css" media=print onload="this.media='all'"><noscript id="rs-critical-noscript"><link rel=stylesheet href="/styles/main.abc123.css"></noscript><body><div>Hello</div>"#;
let new_critical_css = "NEW CSS";
let result = inline_critical_css(html, new_critical_css, Some("/styles/main.def456.css"));
assert!(result.contains("NEW CSS"));
assert!(!result.contains("OLD CSS"));
assert!(!result.contains("abc123"));
assert!(result.contains("def456"));
assert_eq!(result.matches("rs-critical-css").count(), 1);
}
#[test]
fn test_remove_existing_critical_css() {
let html = r#"<link href=/a.css rel=stylesheet><style id="rs-critical-css">body{margin:0}</style><link id="rs-critical-async" rel=stylesheet href="/b.css" media=print onload="this.media='all'"><noscript id="rs-critical-noscript"><link rel=stylesheet href="/b.css"></noscript><body>"#;
let result = remove_existing_critical_css(html);
assert!(!result.contains("rs-critical-css"));
assert!(!result.contains("rs-critical-async"));
assert!(!result.contains("rs-critical-noscript"));
assert!(result.contains("<link href=/a.css rel=stylesheet>"));
}
#[test]
fn test_remove_existing_critical_css_backwards_compat() {
let html = r#"<link href=/a.css rel=stylesheet><style id="critical-css">body{margin:0}</style><body>"#;
let result = remove_existing_critical_css(html);
assert!(!result.contains("critical-css"));
assert!(result.contains("<link href=/a.css rel=stylesheet>"));
}
}