use anyhow::{Context, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RawElement { Script, Style, Textarea, Pre, } /// Conservative HTML minification. /// /// HTML whitespace can be semantically significant, especially between inline /// elements, so this transformer deliberately preserves all whitespace. It only /// removes ordinary comments outside raw-text elements. Conditional comments and /// comment-looking bytes inside script/style/textarea/pre are preserved. pub fn minify(body: &[u8]) -> Result> { let input = std::str::from_utf8(body).context("HTML is not UTF-8")?; let bytes = input.as_bytes(); let mut out = String::with_capacity(input.len()); let mut cursor = 0usize; let mut raw_element: Option = None; while cursor < bytes.len() { if bytes[cursor] == b'<' { if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"") else { out.clear(); out.push_str(input); return Ok(out.into_bytes()); }; let end = end + 3; out.push_str(&input[cursor..end]); cursor = end; continue; } if raw_element.is_none() && bytes[cursor..].starts_with(b"") else { out.clear(); out.push_str(input); return Ok(out.into_bytes()); }; cursor = end + 3; continue; } if let Some(end) = find_tag_end(bytes, cursor + 1) { let tag = &input[cursor..=end]; out.push_str(tag); if let Some((name, closing, self_closing)) = raw_tag(tag) { if closing { if raw_element == Some(name) { raw_element = None; } } else if !self_closing && raw_element.is_none() { raw_element = Some(name); } } cursor = end + 1; continue; } } let ch = input[cursor..] .chars() .next() .expect("cursor is inside UTF-8 input"); out.push(ch); cursor += ch.len_utf8(); } Ok(out.into_bytes()) } fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option { haystack .get(start..)? .windows(needle.len()) .position(|window| window == needle) .map(|offset| start + offset) } fn starts_with_ascii_ci(haystack: &[u8], start: usize, needle: &[u8]) -> bool { haystack .get(start..start.saturating_add(needle.len())) .is_some_and(|value| value.eq_ignore_ascii_case(needle)) } fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option { let mut quote: Option = None; while cursor < bytes.len() { match (quote, bytes[cursor]) { (Some(active), ch) if ch == active => quote = None, (None, b'"' | b'\'') => quote = Some(bytes[cursor]), (None, b'>') => return Some(cursor), _ => {} } cursor += 1; } None } fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> { let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim(); if inner.starts_with('!') || inner.starts_with('?') { return None; } let closing = inner.starts_with('/'); let inner = if closing { inner[1..].trim_start() } else { inner }; let self_closing = inner.ends_with('/'); let name = inner .split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>') .next()?; let name = if name.eq_ignore_ascii_case("script") { RawElement::Script } else if name.eq_ignore_ascii_case("style") { RawElement::Style } else if name.eq_ignore_ascii_case("textarea") { RawElement::Textarea } else if name.eq_ignore_ascii_case("pre") { RawElement::Pre } else { return None; }; Some((name, closing, self_closing)) } #[cfg(test)] include!("html/tests.rs");