1use std::path::Path;
2
3use bytes::Bytes;
4
5use crate::reload::ChangeType;
6
7#[derive(Debug)]
9pub enum MinifyError {
10 NotUtf8,
12 Css(String),
14 Js(String),
16 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
33pub 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
89pub(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 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}