pub fn minify_html(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while !rest.is_empty() {
if let Some(stripped) = rest.strip_prefix("<!--") {
if let Some(end) = stripped.find("-->") {
rest = &stripped[end + 3..];
continue;
}
break;
}
if let Some(consumed) = consume_preserved(rest, "pre") {
out.push_str(&rest[..consumed]);
rest = &rest[consumed..];
continue;
}
if let Some(consumed) = consume_preserved(rest, "textarea") {
out.push_str(&rest[..consumed]);
rest = &rest[consumed..];
continue;
}
if let Some((consumed, rebuilt)) = minify_tag_inner(rest, "style", minify_css) {
out.push_str(&rebuilt);
rest = &rest[consumed..];
continue;
}
if let Some((consumed, rebuilt)) = minify_tag_inner(rest, "script", minify_js) {
out.push_str(&rebuilt);
rest = &rest[consumed..];
continue;
}
let first = rest.chars().next().unwrap();
if first.is_ascii_whitespace() {
let ws_len: usize = rest
.chars()
.take_while(|c| c.is_ascii_whitespace())
.map(char::len_utf8)
.sum();
let after_ws = &rest[ws_len..];
let prev_is_tag_close = out.ends_with('>');
let next_is_tag_open = after_ws.starts_with('<');
if prev_is_tag_close && next_is_tag_open {
rest = after_ws;
continue;
}
out.push(' ');
rest = after_ws;
continue;
}
out.push(first);
rest = &rest[first.len_utf8()..];
}
out
}
fn consume_preserved(rest: &str, tag: &str) -> Option<usize> {
if !starts_tag_ci(rest, tag) {
return None;
}
let close = format!("</{}>", tag);
let pos = rest.find(&close)?;
Some(pos + close.len())
}
fn minify_tag_inner(
rest: &str,
tag: &str,
minifier: fn(&str) -> String,
) -> Option<(usize, String)> {
if !starts_tag_ci(rest, tag) {
return None;
}
let open_end = rest.find('>')?;
let close = format!("</{}>", tag);
let inner = &rest[open_end + 1..];
let inner_pos = inner.find(&close)?;
let inner_content = &inner[..inner_pos];
let total = open_end + 1 + inner_pos + close.len();
let mut rebuilt = String::with_capacity(total);
rebuilt.push_str(&rest[..=open_end]);
rebuilt.push_str(&minifier(inner_content));
rebuilt.push_str(&close);
Some((total, rebuilt))
}
fn starts_tag_ci(rest: &str, tag: &str) -> bool {
let open = format!("<{}", tag);
let open_bytes = open.as_bytes();
let rest_bytes = rest.as_bytes();
if rest_bytes.len() < open_bytes.len() {
return false;
}
for i in 0..open_bytes.len() {
if !rest_bytes[i].eq_ignore_ascii_case(&open_bytes[i]) {
return false;
}
}
rest_bytes
.get(open_bytes.len())
.map(|c| matches!(*c, b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/'))
.unwrap_or(false)
}
pub fn minify_css(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while !rest.is_empty() {
if rest.starts_with("/*") {
match rest[2..].find("*/") {
Some(pos) => {
rest = &rest[2 + pos + 2..];
continue;
}
None => break,
}
}
let first = rest.chars().next().unwrap();
if first.is_ascii_whitespace() {
let ws_len: usize = rest
.chars()
.take_while(|c| c.is_ascii_whitespace())
.map(char::len_utf8)
.sum();
let after_ws = &rest[ws_len..];
let prev = out.chars().last().unwrap_or(' ');
let next = after_ws.chars().next().unwrap_or(' ');
if !out.is_empty() && !is_css_delim(prev) && !is_css_delim(next) {
out.push(' ');
}
rest = after_ws;
continue;
}
if is_css_delim(first) {
while out.ends_with(' ') {
out.pop();
}
out.push(first);
rest = &rest[first.len_utf8()..];
continue;
}
out.push(first);
rest = &rest[first.len_utf8()..];
}
out.replace(";}", "}")
}
fn is_css_delim(c: char) -> bool {
matches!(c, '{' | '}' | ':' | ';' | ',' | '>' | '(' | ')' | '+' | '~')
}
pub fn minify_js(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while !rest.is_empty() {
if rest.starts_with("/*") {
match rest[2..].find("*/") {
Some(pos) => {
rest = &rest[2 + pos + 2..];
continue;
}
None => break,
}
}
if rest.starts_with("//") {
match rest.find('\n') {
Some(pos) => {
rest = &rest[pos..];
continue;
}
None => break,
}
}
let first = rest.chars().next().unwrap();
if first == '"' || first == '\'' || first == '`' {
let quote = first;
let mut len = first.len_utf8();
let mut iter = rest[len..].char_indices();
let mut escaped = false;
for (off, c) in iter.by_ref() {
len = first.len_utf8() + off + c.len_utf8();
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == quote {
break;
}
}
out.push_str(&rest[..len]);
rest = &rest[len..];
continue;
}
if first.is_ascii_whitespace() {
let ws_len: usize = rest
.chars()
.take_while(|c| c.is_ascii_whitespace())
.map(char::len_utf8)
.sum();
let after_ws = &rest[ws_len..];
let prev = out.chars().last().unwrap_or(' ');
let next = after_ws.chars().next().unwrap_or(' ');
if is_js_word(prev) && is_js_word(next) {
out.push(' ');
}
rest = after_ws;
continue;
}
out.push(first);
rest = &rest[first.len_utf8()..];
}
out
}
fn is_js_word(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '$'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_strips_comments_and_trailing_semicolon() {
let css = "/* comment */\n.foo {\n color: red;\n margin: 0;\n}\n";
assert_eq!(minify_css(css), ".foo{color:red;margin:0}");
}
#[test]
fn html_collapses_between_tags() {
let html = "<div>\n <p>hi</p>\n</div>";
assert_eq!(minify_html(html), "<div><p>hi</p></div>");
}
#[test]
fn html_preserves_pre_contents() {
let html = "<div><pre>\n indented\n</pre></div>";
let out = minify_html(html);
assert!(out.contains(" indented"), "got: {}", out);
}
#[test]
fn html_strips_comments() {
let html = "<div><!-- drop me --><span>x</span></div>";
let out = minify_html(html);
assert!(!out.contains("drop me"));
}
#[test]
fn html_preserves_multibyte() {
let html = "<title>Hello - World</title>";
let out = minify_html(html);
assert!(out.contains("-"), "got: {}", out);
}
#[test]
fn js_strips_line_comments() {
let js = "// comment\nvar x = 1;\nvar y = 2;";
let out = minify_js(js);
assert!(!out.contains("comment"));
assert!(out.contains("var x=1"));
}
#[test]
fn js_preserves_strings() {
let js = r#"var s = " spaces ";"#;
let out = minify_js(js);
assert!(out.contains(r#"" spaces ""#));
}
#[test]
fn css_preserves_multibyte_in_content() {
let css = ".x::after { content: ' ↕'; }";
let out = minify_css(css);
assert!(out.contains("↕"), "got: {}", out);
}
}