use std::path::Path;
use bytes::Bytes;
use crate::reload::ChangeType;
#[derive(Debug)]
pub enum MinifyError {
NotUtf8,
Css(String),
Js(String),
Io(std::io::Error),
}
impl std::fmt::Display for MinifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MinifyError::NotUtf8 => write!(f, "input is not valid UTF-8"),
MinifyError::Css(msg) => write!(f, "CSS minification failed: {msg}"),
MinifyError::Js(msg) => write!(f, "JS minification failed: {msg}"),
MinifyError::Io(e) => write!(f, "reading source file failed: {e}"),
}
}
}
impl std::error::Error for MinifyError {}
pub fn minify(bytes: &[u8], change_type: ChangeType) -> Result<Bytes, MinifyError> {
match change_type {
ChangeType::Css => minify_css(bytes),
ChangeType::Script => minify_js(bytes),
ChangeType::Html | ChangeType::Other => Ok(Bytes::copy_from_slice(bytes)),
}
}
fn minify_css(bytes: &[u8]) -> Result<Bytes, MinifyError> {
let source = std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
let mut stylesheet = lightningcss::stylesheet::StyleSheet::parse(
source,
lightningcss::stylesheet::ParserOptions::default(),
)
.map_err(|e| MinifyError::Css(e.to_string()))?;
stylesheet
.minify(lightningcss::stylesheet::MinifyOptions::default())
.map_err(|e| MinifyError::Css(e.to_string()))?;
let result = stylesheet
.to_css(lightningcss::printer::PrinterOptions {
minify: true,
..Default::default()
})
.map_err(|e| MinifyError::Css(e.to_string()))?;
Ok(Bytes::from(result.code.into_bytes()))
}
fn minify_js(bytes: &[u8]) -> Result<Bytes, MinifyError> {
std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
let session = minify_js::Session::new();
let mut output = Vec::new();
minify_js::minify(&session, minify_js::TopLevelMode::Global, bytes, &mut output)
.map_err(|e| MinifyError::Js(format!("{:?}", e)))?;
Ok(Bytes::from(output))
}
pub(crate) fn is_already_minified(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".min.css") || name.ends_with(".min.js"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minifies_css_dropping_whitespace_and_comments() {
let source = b"body {\n /* comment */\n color: red;\n}\n";
let minified = minify(source, ChangeType::Css).expect("valid CSS should minify");
let minified = std::str::from_utf8(&minified).unwrap();
assert!(minified.len() < source.len(), "minified output should be shorter");
assert!(!minified.contains("comment"), "comment should be dropped");
assert!(minified.contains("color:red") || minified.contains("color: red"), "rule should survive: {minified}");
}
#[test]
fn rejects_malformed_css() {
let result = minify(b"body {{{{ color: red; }", ChangeType::Css);
assert!(result.is_err(), "unbalanced braces should be rejected, not silently passed through");
}
#[test]
fn minifies_calc_with_nested_var() {
let source = b"body { top: calc(var(--half) * -1); }";
let minified = minify(source, ChangeType::Css).expect("calc(var()) must minify, not error");
let minified = std::str::from_utf8(&minified).unwrap();
assert!(minified.contains("calc(var(--half)"), "nested var() inside calc() must survive: {minified}");
}
#[test]
fn minifies_nested_gradient_with_rgba_and_var() {
let source = b".x { background: repeating-linear-gradient(45deg, rgba(var(--r), var(--g), var(--b), 0.5) 0px, rgba(0,0,0,0.2) 10px, var(--fallback) 20px); }";
let minified = minify(source, ChangeType::Css)
.expect("multi-layer nested function calls in gradients must minify, not error");
let minified = std::str::from_utf8(&minified).unwrap();
assert!(minified.contains("repeating-linear-gradient"), "gradient must survive: {minified}");
assert!(
minified.contains("var(--r)") && minified.contains("var(--fallback)"),
"nested var()s must survive: {minified}"
);
}
#[test]
fn minifies_has_with_nested_pseudo_class() {
let source = b"table:has(~ tr:not([hidden])) { color: blue; }";
let minified = minify(source, ChangeType::Css).expect(":has() with nested pseudo-class must minify, not error");
let minified = std::str::from_utf8(&minified).unwrap();
assert!(
minified.contains(":has(~tr:not([hidden]))"),
":has() selector must round-trip uncorrupted: {minified}"
);
}
#[test]
fn rejects_malformed_js() {
let result = minify(b"function( {{{ !!!", ChangeType::Script);
assert!(result.is_err(), "malformed JS should be rejected, not silently passed through");
}
#[test]
fn html_and_other_pass_through_unchanged() {
let html = b"<html><body>hi</body></html>";
assert_eq!(&minify(html, ChangeType::Html).unwrap()[..], html);
let other = b"arbitrary binary-ish content";
assert_eq!(&minify(other, ChangeType::Other).unwrap()[..], other);
}
#[test]
fn rejects_non_utf8_input() {
let invalid = [0xff, 0xfe, 0xfd];
assert!(matches!(minify(&invalid, ChangeType::Css), Err(MinifyError::NotUtf8)));
assert!(matches!(minify(&invalid, ChangeType::Script), Err(MinifyError::NotUtf8)));
}
#[test]
fn detects_already_minified_filenames() {
assert!(is_already_minified(Path::new("app.min.js")));
assert!(is_already_minified(Path::new("/a/b/app.min.css")));
assert!(!is_already_minified(Path::new("app.js")), "plain .js is not already minified");
assert!(!is_already_minified(Path::new("app.css")), "plain .css is not already minified");
assert!(
!is_already_minified(Path::new("app.minified.js")),
"must match the exact .min.js/.min.css suffix, not a loose 'min' substring"
);
}
}