1use sitesquatch::{Config, Plugin};
11
12#[derive(Clone)]
13pub struct MinifyOptions {
14 pub collapse_whitespace: bool,
15 pub remove_comments: bool,
16 pub short_doctype: bool,
17 pub shorten_attributes: bool,
18}
19
20impl Default for MinifyOptions {
21 fn default() -> Self {
22 MinifyOptions { collapse_whitespace: true, remove_comments: true, short_doctype: true, shorten_attributes: true }
23 }
24}
25
26#[derive(Default)]
27pub struct MinifyPlugin {
28 pub options: MinifyOptions,
29}
30
31impl Plugin for MinifyPlugin {
32 fn name(&self) -> &str {
33 "minify"
34 }
35
36 fn register(&self, config: &mut Config) {
37 let opts = self.options.clone();
38 config.add_transform("minify-html", move |ctx, html| if ctx.is_html() { Ok(minify(&html, &opts)) } else { Ok(html) });
39 }
40}
41
42const BLOCK: &[&str] = &[
44 "html", "head", "body", "title", "meta", "link", "script", "style", "base", "noscript", "template", "div", "p", "section", "article", "aside",
45 "header", "footer", "nav", "main", "ul", "ol", "li", "dl", "dt", "dd", "h1", "h2", "h3", "h4", "h5", "h6", "table", "thead", "tbody", "tfoot",
46 "tr", "td", "th", "caption", "colgroup", "col", "figure", "figcaption", "picture", "source", "blockquote", "form", "fieldset", "legend", "hr",
47 "pre", "address", "details", "summary", "dialog", "iframe", "video", "audio", "canvas", "option", "optgroup", "symbol", "g", "path",
48 "defs", "circle", "rect", "polygon", "line", "ellipse", "!doctype",
49];
50const VOID: &[&str] = &["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"];
51const VISIBLE: &[&str] = &["img", "svg", "input", "select", "textarea", "button", "video", "audio", "iframe", "canvas", "br", "wbr"];
53const RAW: &[&str] = &["script", "style", "pre", "textarea"];
54
55fn tag_name(tag: &str) -> String {
56 tag.trim_start_matches('<').trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("").to_ascii_lowercase()
57}
58
59pub fn minify(html: &str, opts: &MinifyOptions) -> String {
60 enum Tok<'a> {
62 Tag(String, String), Text(&'a str),
64 Raw(&'a str),
65 }
66 let mut toks: Vec<Tok> = Vec::new();
67 let mut i = 0;
68 let b = html.as_bytes();
69 while i < html.len() {
70 if b[i] == b'<' {
71 if html[i..].starts_with("<!--") {
72 let end = html[i..].find("-->").map(|e| i + e + 3).unwrap_or(html.len());
73 let comment = &html[i..end];
74 let keep = !opts.remove_comments || comment.starts_with("<!--[if") || comment.starts_with("<!--!");
75 if keep {
76 toks.push(Tok::Raw(comment));
77 }
78 i = end;
79 continue;
80 }
81 let next = html[i + 1..].chars().next().unwrap_or(' ');
82 if next.is_ascii_alphabetic() || next == '/' || next == '!' || next == '?' {
83 let mut j = i + 1;
85 let mut quote: Option<u8> = None;
86 while j < b.len() {
87 match (quote, b[j]) {
88 (Some(q), c) if c == q => quote = None,
89 (None, b'"') | (None, b'\'') => quote = Some(b[j]),
90 (None, b'>') => break,
91 _ => {}
92 }
93 j += 1;
94 }
95 let raw_tag = &html[i..(j + 1).min(html.len())];
96 let name = tag_name(raw_tag);
97 let mut markup = if name == "!doctype" && opts.short_doctype { "<!doctype html>".to_string() } else { collapse_tag(raw_tag) };
98 if VOID.contains(&name.as_str()) && markup.ends_with("/>") {
100 markup.truncate(markup.len() - 2);
101 let trimmed = markup.trim_end().len();
102 markup.truncate(trimmed);
103 markup.push('>');
104 }
105 let closing = raw_tag.starts_with("</");
106 if opts.shorten_attributes && !closing && name != "!doctype" && !name.starts_with('!') && !name.starts_with('?') {
107 markup = shorten_attributes(&markup, &name);
108 }
109 toks.push(Tok::Tag(markup, if closing { format!("/{}", name) } else { name.clone() }));
110 i = j + 1;
111 if !closing && RAW.contains(&name.as_str()) && !raw_tag.ends_with("/>") {
112 let close = format!("</{}", name);
113 let end = find_ci(&html[i..], &close).map(|e| i + e).unwrap_or(html.len());
114 toks.push(Tok::Raw(&html[i..end]));
115 i = end;
116 }
117 continue;
118 }
119 }
120 let next_char = i + html[i..].chars().next().map(char::len_utf8).unwrap_or(1);
121 let end = html[next_char..].find('<').map(|e| next_char + e).unwrap_or(html.len());
122 toks.push(Tok::Text(&html[i..end]));
123 i = end;
124 }
125
126 #[derive(PartialEq)]
129 enum Edge {
130 Boundary,
131 Content,
132 }
133 let edge = |t: &Tok| -> Option<Edge> {
134 match t {
135 Tok::Tag(_, name) => {
136 let bare = name.trim_start_matches('/');
137 if BLOCK.contains(&bare) {
138 Some(Edge::Boundary)
139 } else if VISIBLE.contains(&bare) {
140 Some(Edge::Content)
141 } else {
142 None }
144 }
145 Tok::Text(t) if t.trim().is_empty() => None,
146 _ => Some(Edge::Content),
147 }
148 };
149 let mut out = String::with_capacity(html.len());
150 let mut pending_space = false;
152 for idx in 0..toks.len() {
153 if edge(&toks[idx]).is_some() && !matches!(toks[idx], Tok::Text(_)) {
154 pending_space = false;
155 }
156 match &toks[idx] {
157 Tok::Tag(m, _) => out.push_str(m),
158 Tok::Raw(r) => {
159 let script_like = idx > 0 && matches!(&toks[idx - 1], Tok::Tag(_, n) if n == "script" || n == "style");
160 out.push_str(if script_like && opts.collapse_whitespace { r.trim() } else { r });
161 }
162 Tok::Text(t) if !opts.collapse_whitespace => out.push_str(t),
163 Tok::Text(t) => {
164 let mut s = collapse_ws(t);
165 let before = toks[..idx].iter().rev().find_map(edge).unwrap_or(Edge::Boundary);
166 if before == Edge::Boundary || pending_space {
167 s = s.trim_start().to_string();
168 }
169 let after = toks[idx + 1..].iter().find_map(edge).unwrap_or(Edge::Boundary);
170 if after == Edge::Boundary {
171 s = s.trim_end().to_string();
172 }
173 if !s.is_empty() {
174 pending_space = s.ends_with(' ');
175 }
176 out.push_str(&s);
177 }
178 }
179 }
180 out
181}
182
183fn find_ci(hay: &str, needle: &str) -> Option<usize> {
184 let lower = hay.to_ascii_lowercase();
185 lower.find(&needle.to_ascii_lowercase())
186}
187
188fn collapse_ws(s: &str) -> String {
189 let mut out = String::with_capacity(s.len());
190 let mut in_ws = false;
191 for c in s.chars() {
192 if c.is_ascii_whitespace() {
193 if !in_ws {
194 out.push(' ');
195 }
196 in_ws = true;
197 } else {
198 out.push(c);
199 in_ws = false;
200 }
201 }
202 out
203}
204
205fn collapse_tag(tag: &str) -> String {
207 let mut out = String::with_capacity(tag.len());
208 let mut quote: Option<char> = None;
209 let mut pending_space = false;
210 for c in tag.chars() {
211 match quote {
212 Some(q) => {
213 out.push(c);
214 if c == q {
215 quote = None;
216 }
217 }
218 None if c.is_ascii_whitespace() => pending_space = true,
219 None => {
220 if pending_space && c != '>' && !(c == '/' && tag.ends_with("/>")) && !out.ends_with('<') {
221 out.push(' ');
222 }
223 pending_space = false;
224 if c == '"' || c == '\'' {
225 quote = Some(c);
226 }
227 out.push(c);
228 }
229 }
230 }
231 out
232}
233
234const BOOLEAN: &[&str] = &[
236 "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "inert", "ismap",
237 "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected",
238];
239const URL_ATTRS: &[&str] = &["href", "src", "action", "poster", "cite", "data", "formaction"];
240
241struct Attr<'a> {
242 name: &'a str,
243 value: Option<(Option<char>, &'a str)>,
245}
246
247fn parse_attrs(tag: &str) -> Option<(&str, Vec<Attr<'_>>, &str)> {
250 let inner = tag.strip_prefix('<')?;
251 let (inner, end) = match inner.strip_suffix("/>") {
252 Some(i) => (i, "/>"),
253 None => (inner.strip_suffix('>')?, ">"),
254 };
255 let name_end = inner.find(|c: char| c.is_ascii_whitespace()).unwrap_or(inner.len());
256 let (name, mut rest) = inner.split_at(name_end);
257 let mut attrs = Vec::new();
258 loop {
259 rest = rest.trim_start();
260 if rest.is_empty() {
261 break;
262 }
263 let n = rest.find(|c: char| c.is_ascii_whitespace() || c == '=').unwrap_or(rest.len());
264 if n == 0 {
265 return None;
266 }
267 let attr_name = &rest[..n];
268 rest = rest[n..].trim_start();
269 let value = if let Some(after) = rest.strip_prefix('=') {
270 let after = after.trim_start();
271 match after.chars().next() {
272 Some(q @ ('"' | '\'')) => {
273 let close = after[1..].find(q)? + 1;
274 rest = &after[close + 1..];
275 Some((Some(q), &after[1..close]))
276 }
277 Some(_) => {
278 let e = after.find(|c: char| c.is_ascii_whitespace()).unwrap_or(after.len());
279 rest = &after[e..];
280 Some((None, &after[..e]))
281 }
282 None => return None,
283 }
284 } else {
285 None
286 };
287 attrs.push(Attr { name: attr_name, value });
288 }
289 Some((name, attrs, end))
290}
291
292fn shorten_attributes(tag: &str, element: &str) -> String {
294 let Some((name, attrs, end)) = parse_attrs(tag) else { return tag.to_string() };
295 let viewport = element == "meta" && attrs.iter().any(|a| a.name.eq_ignore_ascii_case("name") && a.value.is_some_and(|(_, v)| v.trim().eq_ignore_ascii_case("viewport")));
296 let mut out = String::with_capacity(tag.len());
297 out.push('<');
298 out.push_str(name);
299 for a in &attrs {
300 out.push(' ');
301 out.push_str(a.name);
302 let lower = a.name.to_ascii_lowercase();
303 let Some((quote, value)) = a.value else { continue };
304 if BOOLEAN.contains(&lower.as_str()) && (value.is_empty() || value.eq_ignore_ascii_case(&lower)) {
305 continue;
306 }
307 let value = match lower.as_str() {
308 "class" | "sizes" => collapse_ws(value).trim().to_string(),
309 "srcset" => collapse_ws(value).trim().replace(", ", ","),
310 "content" if viewport => value.split(',').map(|part| part.split('=').map(str::trim).collect::<Vec<_>>().join("=")).collect::<Vec<_>>().join(","),
311 l if URL_ATTRS.contains(&l) => value.trim().to_string(),
312 _ => value.to_string(),
313 };
314 out.push('=');
315 match quote {
316 Some(q) => {
317 out.push(q);
318 out.push_str(&value);
319 out.push(q);
320 }
321 None => out.push_str(&value),
322 }
323 }
324 if end == "/>" {
325 out.push('/');
326 }
327 out.push('>');
328 out
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn m(s: &str) -> String {
336 minify(s, &MinifyOptions::default())
337 }
338
339 #[test]
340 fn collapses_whitespace_like_html_minifier() {
341 assert_eq!(m("<!DOCTYPE html>\n<html>\n <head>\n <title>\n Hi – there\n </title>\n </head>"), "<!doctype html><html><head><title>Hi – there</title></head>");
342 assert_eq!(m("<p>Hello <strong>big</strong>\n world</p>"), "<p>Hello <strong>big</strong> world</p>");
343 assert_eq!(m("<img\n class=\"a\"\n src=\"b c\" >"), "<img class=\"a\" src=\"b c\">");
344 assert_eq!(m("<div><!-- gone --><pre> keep\n this </pre></div>"), "<div><pre> keep\n this </pre></div>");
345 assert_eq!(m("<script>\n if (a < b) {}\n</script>"), "<script>if (a < b) {}</script>");
346 assert_eq!(m("<nav><a>\n Home\n</a>\n<a>\n About\n</a></nav>"), "<nav><a>Home </a><a>About</a></nav>");
347 assert_eq!(m("<a href=\"/\">Home\n</a>"), "<a href=\"/\">Home</a>");
348 assert_eq!(m("<link rel=\"canonical\" href=\"/\" /><svg><path d=\"M0\"/></svg>"), "<link rel=\"canonical\" href=\"/\"><svg><path d=\"M0\"/></svg>");
349 assert_eq!(m("<p>—dash — <b>é</b></p>"), "<p>—dash — <b>é</b></p>");
350 assert_eq!(m("<div>\n <a href=\"/\">\n Home\n </a>\n</div>"), "<div><a href=\"/\">Home</a></div>");
351 assert_eq!(m("<a><svg></svg>\n Buy me\n</a>\n</div>"), "<a><svg></svg> Buy me</a></div>");
352 assert_eq!(m("<script>\n var a = 1;\n</script>"), "<script>var a = 1;</script>");
353 }
354
355 #[test]
356 fn shortens_attributes() {
357 assert_eq!(m("<script defer=\"defer\" src=\"/a.js\"></script>"), "<script defer src=\"/a.js\"></script>");
358 assert_eq!(m("<video autoplay=\"\" muted loop='LOOP' controls=\"false\">"), "<video autoplay muted loop controls=\"false\">");
359 assert_eq!(m("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"), "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">");
360 assert_eq!(m("<meta name=\"description\" content=\"a, b = c\">"), "<meta name=\"description\" content=\"a, b = c\">");
361 assert_eq!(m("<img class=\" a\n b \" srcset=\"/a.jpg 1x, /b.jpg 2x\" src=\" /a.jpg \">"), "<img class=\"a b\" srcset=\"/a.jpg 1x,/b.jpg 2x\" src=\"/a.jpg\">");
362 assert_eq!(m("<a href=x title=\"a b\">"), "<a href=x title=\"a b\">");
363 assert_eq!(m("<details open=\"\"><summary>s</summary></details>"), "<details open><summary>s</summary></details>");
364 assert_eq!(m("<path d=\"M0 0\"/>"), "<path d=\"M0 0\"/>");
365 assert_eq!(m("<input value=\" keep \" hidden=\"until-found\">"), "<input value=\" keep \" hidden=\"until-found\">");
366 let off = MinifyOptions { shorten_attributes: false, ..MinifyOptions::default() };
367 assert_eq!(minify("<script defer=\"defer\"></script>", &off), "<script defer=\"defer\"></script>");
368 }
369}