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 while minifying it on demand.
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 `lightningcss`, `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
52pub(crate) fn minify_css(bytes: &[u8]) -> Result<Bytes, MinifyError> {
53    let source = std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
54
55    let mut stylesheet = lightningcss::stylesheet::StyleSheet::parse(
56        source,
57        lightningcss::stylesheet::ParserOptions::default(),
58    )
59    .map_err(|e| MinifyError::Css(e.to_string()))?;
60
61    stylesheet
62        .minify(lightningcss::stylesheet::MinifyOptions::default())
63        .map_err(|e| MinifyError::Css(e.to_string()))?;
64
65    let result = stylesheet
66        .to_css(lightningcss::printer::PrinterOptions {
67            minify: true,
68            ..Default::default()
69        })
70        .map_err(|e| MinifyError::Css(e.to_string()))?;
71
72    Ok(Bytes::from(result.code.into_bytes()))
73}
74
75fn minify_js(bytes: &[u8]) -> Result<Bytes, MinifyError> {
76    std::str::from_utf8(bytes).map_err(|_| MinifyError::NotUtf8)?;
77    let session = minify_js::Session::new();
78    let mut output = Vec::new();
79    minify_js::minify(
80        &session,
81        minify_js::TopLevelMode::Global,
82        bytes,
83        &mut output,
84    )
85    .map_err(|e| MinifyError::Js(format!("{:?}", e)))?;
86    Ok(Bytes::from(output))
87}
88
89/// True if `path`'s filename indicates it's already minified (`*.min.css` /
90/// `*.min.js`). Such files should be served as-is — running a minifier on
91/// already-minified input is wasted work at best and a correctness risk at worst (a
92/// minifier is not guaranteed to be idempotent on its own output).
93pub(crate) fn is_already_minified(path: &Path) -> bool {
94    path.file_name()
95        .and_then(|name| name.to_str())
96        .is_some_and(|name| name.ends_with(".min.css") || name.ends_with(".min.js"))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn minifies_css_dropping_whitespace_and_comments() {
105        let source = b"body {\n  /* comment */\n  color: red;\n}\n";
106        let minified = minify(source, ChangeType::Css).expect("valid CSS should minify");
107        let minified = std::str::from_utf8(&minified).unwrap();
108
109        assert!(
110            minified.len() < source.len(),
111            "minified output should be shorter"
112        );
113        assert!(!minified.contains("comment"), "comment should be dropped");
114        assert!(
115            minified.contains("color:red") || minified.contains("color: red"),
116            "rule should survive: {minified}"
117        );
118    }
119
120    #[test]
121    fn rejects_malformed_css() {
122        // Note: an unterminated declaration (e.g. `"body { color: "`) is NOT malformed
123        // under lightningcss — its spec-compliant EOF recovery closes it to
124        // `body{color: }` rather than erroring, unlike the previous minifier
125        // (css-minify), whose stricter — and, per the bug this module's history fixed,
126        // overly fragile — parser rejected it. Unbalanced braces is a case that
127        // genuinely has no valid recovery.
128        let result = minify(b"body {{{{ color: red; }", ChangeType::Css);
129        assert!(
130            result.is_err(),
131            "unbalanced braces should be rejected, not silently passed through"
132        );
133    }
134
135    #[test]
136    fn minifies_calc_with_nested_var() {
137        let source = b"body { top: calc(var(--half) * -1); }";
138        let minified = minify(source, ChangeType::Css).expect("calc(var()) must minify, not error");
139        let minified = std::str::from_utf8(&minified).unwrap();
140        assert!(
141            minified.contains("calc(var(--half)"),
142            "nested var() inside calc() must survive: {minified}"
143        );
144    }
145
146    #[test]
147    fn minifies_nested_gradient_with_rgba_and_var() {
148        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); }";
149        let minified = minify(source, ChangeType::Css)
150            .expect("multi-layer nested function calls in gradients must minify, not error");
151        let minified = std::str::from_utf8(&minified).unwrap();
152        assert!(
153            minified.contains("repeating-linear-gradient"),
154            "gradient must survive: {minified}"
155        );
156        assert!(
157            minified.contains("var(--r)") && minified.contains("var(--fallback)"),
158            "nested var()s must survive: {minified}"
159        );
160    }
161
162    #[test]
163    fn minifies_has_with_nested_pseudo_class() {
164        let source = b"table:has(~ tr:not([hidden])) { color: blue; }";
165        let minified = minify(source, ChangeType::Css)
166            .expect(":has() with nested pseudo-class must minify, not error");
167        let minified = std::str::from_utf8(&minified).unwrap();
168        assert!(
169            minified.contains(":has(~tr:not([hidden]))"),
170            ":has() selector must round-trip uncorrupted: {minified}"
171        );
172    }
173
174    #[test]
175    fn rejects_malformed_js() {
176        let result = minify(b"function( {{{ !!!", ChangeType::Script);
177        assert!(
178            result.is_err(),
179            "malformed JS should be rejected, not silently passed through"
180        );
181    }
182
183    #[test]
184    fn html_and_other_pass_through_unchanged() {
185        let html = b"<html><body>hi</body></html>";
186        assert_eq!(&minify(html, ChangeType::Html).unwrap()[..], html);
187
188        let other = b"arbitrary binary-ish content";
189        assert_eq!(&minify(other, ChangeType::Other).unwrap()[..], other);
190    }
191
192    #[test]
193    fn rejects_non_utf8_input() {
194        let invalid = [0xff, 0xfe, 0xfd];
195        assert!(matches!(
196            minify(&invalid, ChangeType::Css),
197            Err(MinifyError::NotUtf8)
198        ));
199        assert!(matches!(
200            minify(&invalid, ChangeType::Script),
201            Err(MinifyError::NotUtf8)
202        ));
203    }
204
205    #[test]
206    fn detects_already_minified_filenames() {
207        assert!(is_already_minified(Path::new("app.min.js")));
208        assert!(is_already_minified(Path::new("/a/b/app.min.css")));
209        assert!(
210            !is_already_minified(Path::new("app.js")),
211            "plain .js is not already minified"
212        );
213        assert!(
214            !is_already_minified(Path::new("app.css")),
215            "plain .css is not already minified"
216        );
217        assert!(
218            !is_already_minified(Path::new("app.minified.js")),
219            "must match the exact .min.js/.min.css suffix, not a loose 'min' substring"
220        );
221    }
222}