Skip to main content

mini_static/
minify.rs

1use std::path::Path;
2
3use bytes::Bytes;
4
5use crate::reload::ChangeType;
6
7/// Why [`minify`] could not produce output for the given bytes.
8#[derive(Debug)]
9pub enum MinifyError {
10    /// The bytes were not valid UTF-8 (both minifiers work on text, not arbitrary bytes).
11    NotUtf8,
12    /// The CSS minifier rejected the input.
13    Css(String),
14    /// The JS minifier rejected the input.
15    Js(String),
16    /// Reading the source file failed (see [`crate::MinifyCache`]).
17    Io(std::io::Error),
18}
19
20impl std::fmt::Display for MinifyError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            MinifyError::NotUtf8 => write!(f, "input is not valid UTF-8"),
24            MinifyError::Css(msg) => write!(f, "CSS minification failed: {msg}"),
25            MinifyError::Js(msg) => write!(f, "JS minification failed: {msg}"),
26            MinifyError::Io(e) => write!(f, "reading source file failed: {e}"),
27        }
28    }
29}
30
31impl std::error::Error for MinifyError {}
32
33/// Minify `bytes` according to `change_type`'s file kind.
34///
35/// `Css` is minified with `css-minify`, `Script` with `minify-js`. `Html` and `Other`
36/// pass through unchanged — this function is never called for those kinds (see
37/// `Server::with_minify`), but passthrough is the correct behavior if it ever is.
38///
39/// # Errors
40///
41/// Returns `Err` if `bytes` isn't valid UTF-8, or if the relevant minifier rejects the
42/// input as malformed. Never panics — malformed CSS/JS on disk is a real possibility
43/// (a hand-edited file, a build tool's bug), not a state to unwrap through.
44pub fn minify(bytes: &[u8], change_type: ChangeType) -> Result<Bytes, MinifyError> {
45    match change_type {
46        ChangeType::Css => minify_css(bytes),
47        ChangeType::Script => minify_js(bytes),
48        ChangeType::Html | ChangeType::Other => Ok(Bytes::copy_from_slice(bytes)),
49    }
50}
51
52fn minify_css(bytes: &[u8]) -> Result<Bytes, MinifyError> {
53    let source = std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
54    let minified = css_minify::optimizations::Minifier::default()
55        .minify(source, css_minify::optimizations::Level::Three)
56        .map_err(|e| MinifyError::Css(e.to_string()))?;
57    Ok(Bytes::from(minified.into_bytes()))
58}
59
60fn minify_js(bytes: &[u8]) -> Result<Bytes, MinifyError> {
61    std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
62    let session = minify_js::Session::new();
63    let mut output = Vec::new();
64    minify_js::minify(&session, minify_js::TopLevelMode::Global, bytes, &mut output)
65        .map_err(|e| MinifyError::Js(format!("{:?}", e)))?;
66    Ok(Bytes::from(output))
67}
68
69/// True if `path`'s filename indicates it's already minified (`*.min.css` /
70/// `*.min.js`). Such files should be served as-is — running a minifier on
71/// already-minified input is wasted work at best and a correctness risk at worst (a
72/// minifier is not guaranteed to be idempotent on its own output).
73pub(crate) fn is_already_minified(path: &Path) -> bool {
74    path.file_name()
75        .and_then(|name| name.to_str())
76        .is_some_and(|name| name.ends_with(".min.css") || name.ends_with(".min.js"))
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn minifies_css_dropping_whitespace_and_comments() {
85        let source = b"body {\n  /* comment */\n  color: red;\n}\n";
86        let minified = minify(source, ChangeType::Css).expect("valid CSS should minify");
87        let minified = std::str::from_utf8(&minified).unwrap();
88
89        assert!(minified.len() < source.len(), "minified output should be shorter");
90        assert!(!minified.contains("comment"), "comment should be dropped");
91        assert!(minified.contains("color:red") || minified.contains("color: red"), "rule should survive: {minified}");
92    }
93
94    #[test]
95    fn rejects_malformed_css() {
96        let result = minify(b"body { color: ", ChangeType::Css);
97        assert!(result.is_err(), "unterminated CSS should be rejected, not silently passed through");
98    }
99
100    #[test]
101    fn rejects_malformed_js() {
102        let result = minify(b"function( {{{ !!!", ChangeType::Script);
103        assert!(result.is_err(), "malformed JS should be rejected, not silently passed through");
104    }
105
106    #[test]
107    fn html_and_other_pass_through_unchanged() {
108        let html = b"<html><body>hi</body></html>";
109        assert_eq!(&minify(html, ChangeType::Html).unwrap()[..], html);
110
111        let other = b"arbitrary binary-ish content";
112        assert_eq!(&minify(other, ChangeType::Other).unwrap()[..], other);
113    }
114
115    #[test]
116    fn rejects_non_utf8_input() {
117        let invalid = [0xff, 0xfe, 0xfd];
118        assert!(matches!(minify(&invalid, ChangeType::Css), Err(MinifyError::NotUtf8)));
119        assert!(matches!(minify(&invalid, ChangeType::Script), Err(MinifyError::NotUtf8)));
120    }
121
122    #[test]
123    fn detects_already_minified_filenames() {
124        assert!(is_already_minified(Path::new("app.min.js")));
125        assert!(is_already_minified(Path::new("/a/b/app.min.css")));
126        assert!(!is_already_minified(Path::new("app.js")), "plain .js is not already minified");
127        assert!(!is_already_minified(Path::new("app.css")), "plain .css is not already minified");
128        assert!(
129            !is_already_minified(Path::new("app.minified.js")),
130            "must match the exact .min.js/.min.css suffix, not a loose 'min' substring"
131        );
132    }
133}