use sitesquatch::{Config, Plugin};
#[derive(Clone)]
pub struct MinifyOptions {
pub collapse_whitespace: bool,
pub remove_comments: bool,
pub short_doctype: bool,
}
impl Default for MinifyOptions {
fn default() -> Self {
MinifyOptions { collapse_whitespace: true, remove_comments: true, short_doctype: true }
}
}
#[derive(Default)]
pub struct MinifyPlugin {
pub options: MinifyOptions,
}
impl Plugin for MinifyPlugin {
fn name(&self) -> &str {
"minify"
}
fn register(&self, config: &mut Config) {
let opts = self.options.clone();
config.add_transform("minify-html", move |ctx, html| if ctx.is_html() { Ok(minify(&html, &opts)) } else { Ok(html) });
}
}
const BLOCK: &[&str] = &[
"html", "head", "body", "title", "meta", "link", "script", "style", "base", "noscript", "template", "div", "p", "section", "article", "aside",
"header", "footer", "nav", "main", "ul", "ol", "li", "dl", "dt", "dd", "h1", "h2", "h3", "h4", "h5", "h6", "table", "thead", "tbody", "tfoot",
"tr", "td", "th", "caption", "colgroup", "col", "figure", "figcaption", "picture", "source", "blockquote", "form", "fieldset", "legend", "hr",
"pre", "address", "details", "summary", "dialog", "iframe", "video", "audio", "canvas", "option", "optgroup", "symbol", "g", "path",
"defs", "circle", "rect", "polygon", "line", "ellipse", "!doctype",
];
const VOID: &[&str] = &["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"];
const VISIBLE: &[&str] = &["img", "svg", "input", "select", "textarea", "button", "video", "audio", "iframe", "canvas", "br", "wbr"];
const RAW: &[&str] = &["script", "style", "pre", "textarea"];
fn tag_name(tag: &str) -> String {
tag.trim_start_matches('<').trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("").to_ascii_lowercase()
}
pub fn minify(html: &str, opts: &MinifyOptions) -> String {
enum Tok<'a> {
Tag(String, String), Text(&'a str),
Raw(&'a str),
}
let mut toks: Vec<Tok> = Vec::new();
let mut i = 0;
let b = html.as_bytes();
while i < html.len() {
if b[i] == b'<' {
if html[i..].starts_with("<!--") {
let end = html[i..].find("-->").map(|e| i + e + 3).unwrap_or(html.len());
let comment = &html[i..end];
let keep = !opts.remove_comments || comment.starts_with("<!--[if") || comment.starts_with("<!--!");
if keep {
toks.push(Tok::Raw(comment));
}
i = end;
continue;
}
let next = html[i + 1..].chars().next().unwrap_or(' ');
if next.is_ascii_alphabetic() || next == '/' || next == '!' || next == '?' {
let mut j = i + 1;
let mut quote: Option<u8> = None;
while j < b.len() {
match (quote, b[j]) {
(Some(q), c) if c == q => quote = None,
(None, b'"') | (None, b'\'') => quote = Some(b[j]),
(None, b'>') => break,
_ => {}
}
j += 1;
}
let raw_tag = &html[i..(j + 1).min(html.len())];
let name = tag_name(raw_tag);
let mut markup = if name == "!doctype" && opts.short_doctype { "<!doctype html>".to_string() } else { collapse_tag(raw_tag) };
if VOID.contains(&name.as_str()) && markup.ends_with("/>") {
markup.truncate(markup.len() - 2);
let trimmed = markup.trim_end().len();
markup.truncate(trimmed);
markup.push('>');
}
let closing = raw_tag.starts_with("</");
toks.push(Tok::Tag(markup, if closing { format!("/{}", name) } else { name.clone() }));
i = j + 1;
if !closing && RAW.contains(&name.as_str()) && !raw_tag.ends_with("/>") {
let close = format!("</{}", name);
let end = find_ci(&html[i..], &close).map(|e| i + e).unwrap_or(html.len());
toks.push(Tok::Raw(&html[i..end]));
i = end;
}
continue;
}
}
let next_char = i + html[i..].chars().next().map(char::len_utf8).unwrap_or(1);
let end = html[next_char..].find('<').map(|e| next_char + e).unwrap_or(html.len());
toks.push(Tok::Text(&html[i..end]));
i = end;
}
#[derive(PartialEq)]
enum Edge {
Boundary,
Content,
}
let edge = |t: &Tok| -> Option<Edge> {
match t {
Tok::Tag(_, name) => {
let bare = name.trim_start_matches('/');
if BLOCK.contains(&bare) {
Some(Edge::Boundary)
} else if VISIBLE.contains(&bare) {
Some(Edge::Content)
} else {
None }
}
Tok::Text(t) if t.trim().is_empty() => None,
_ => Some(Edge::Content),
}
};
let mut out = String::with_capacity(html.len());
let mut pending_space = false;
for idx in 0..toks.len() {
if edge(&toks[idx]).is_some() && !matches!(toks[idx], Tok::Text(_)) {
pending_space = false;
}
match &toks[idx] {
Tok::Tag(m, _) => out.push_str(m),
Tok::Raw(r) => {
let script_like = idx > 0 && matches!(&toks[idx - 1], Tok::Tag(_, n) if n == "script" || n == "style");
out.push_str(if script_like && opts.collapse_whitespace { r.trim() } else { r });
}
Tok::Text(t) if !opts.collapse_whitespace => out.push_str(t),
Tok::Text(t) => {
let mut s = collapse_ws(t);
let before = toks[..idx].iter().rev().find_map(edge).unwrap_or(Edge::Boundary);
if before == Edge::Boundary || pending_space {
s = s.trim_start().to_string();
}
let after = toks[idx + 1..].iter().find_map(edge).unwrap_or(Edge::Boundary);
if after == Edge::Boundary {
s = s.trim_end().to_string();
}
if !s.is_empty() {
pending_space = s.ends_with(' ');
}
out.push_str(&s);
}
}
}
out
}
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let lower = hay.to_ascii_lowercase();
lower.find(&needle.to_ascii_lowercase())
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for c in s.chars() {
if c.is_ascii_whitespace() {
if !in_ws {
out.push(' ');
}
in_ws = true;
} else {
out.push(c);
in_ws = false;
}
}
out
}
fn collapse_tag(tag: &str) -> String {
let mut out = String::with_capacity(tag.len());
let mut quote: Option<char> = None;
let mut pending_space = false;
for c in tag.chars() {
match quote {
Some(q) => {
out.push(c);
if c == q {
quote = None;
}
}
None if c.is_ascii_whitespace() => pending_space = true,
None => {
if pending_space && c != '>' && !(c == '/' && tag.ends_with("/>")) && !out.ends_with('<') {
out.push(' ');
}
pending_space = false;
if c == '"' || c == '\'' {
quote = Some(c);
}
out.push(c);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn m(s: &str) -> String {
minify(s, &MinifyOptions::default())
}
#[test]
fn collapses_whitespace_like_html_minifier() {
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>");
assert_eq!(m("<p>Hello <strong>big</strong>\n world</p>"), "<p>Hello <strong>big</strong> world</p>");
assert_eq!(m("<img\n class=\"a\"\n src=\"b c\" >"), "<img class=\"a\" src=\"b c\">");
assert_eq!(m("<div><!-- gone --><pre> keep\n this </pre></div>"), "<div><pre> keep\n this </pre></div>");
assert_eq!(m("<script>\n if (a < b) {}\n</script>"), "<script>if (a < b) {}</script>");
assert_eq!(m("<nav><a>\n Home\n</a>\n<a>\n About\n</a></nav>"), "<nav><a>Home </a><a>About</a></nav>");
assert_eq!(m("<a href=\"/\">Home\n</a>"), "<a href=\"/\">Home</a>");
assert_eq!(m("<link rel=\"canonical\" href=\"/\" /><svg><path d=\"M0\"/></svg>"), "<link rel=\"canonical\" href=\"/\"><svg><path d=\"M0\"/></svg>");
assert_eq!(m("<p>—dash — <b>é</b></p>"), "<p>—dash — <b>é</b></p>");
assert_eq!(m("<div>\n <a href=\"/\">\n Home\n </a>\n</div>"), "<div><a href=\"/\">Home</a></div>");
assert_eq!(m("<a><svg></svg>\n Buy me\n</a>\n</div>"), "<a><svg></svg> Buy me</a></div>");
assert_eq!(m("<script>\n var a = 1;\n</script>"), "<script>var a = 1;</script>");
}
}