Skip to main content

sitesquatch_plugin_minify/
lib.rs

1//! Conservative HTML minification, modelled on html-minifier-terser with
2//! `collapseWhitespace`, `removeComments` and `useShortDoctype`.
3//!
4//! With `shorten_attributes`, boolean attributes lose their values
5//! (`defer="defer"` to `defer`), and whitespace that doesn't matter comes out of
6//! `class`, `srcset`, `sizes`, URL attributes and the viewport `<meta>`.
7//! Attribute order and quoting are left alone; `<pre>`, `<textarea>`,
8//! `<script>` and `<style>` contents are copied verbatim.
9
10use 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
42/// Elements whose surrounding whitespace is insignificant.
43const 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"];
51/// Inline elements that render something, so adjacent spaces matter.
52const 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    // Split into tokens: tags/comments and text.
61    enum Tok<'a> {
62        Tag(String, String), // (markup, name)
63        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                // Find the end of the tag, honouring quoted attribute values.
84                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                // Like html-minifier: drop the closing slash on HTML void elements (not SVG).
99                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    // Inline tags are "transparent" when deciding whether whitespace sits on a
127    // block boundary: in `<div><a> Home</a></div>` the spaces are dropped.
128    #[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 // transparent inline tag
143                }
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    // Did the most recent text end in a space (with only inline tags since)?
151    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
205/// Collapse whitespace between attributes, leaving quoted values alone.
206fn 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
234/// HTML attributes whose presence is the value (`defer="defer"` means `defer`).
235const 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    /// `(quote, value)`; the quote is `None` for an unquoted value.
244    value: Option<(Option<char>, &'a str)>,
245}
246
247/// Split the attributes out of a start tag (after whitespace collapsing).
248/// Returns `None` for anything it doesn't follow, which is then left alone.
249fn 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
292/// Shorten a start tag's attributes without changing what they mean.
293fn 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}