134 lines
4.3 KiB
Rust
134 lines
4.3 KiB
Rust
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<Vec<u8>> {
|
|
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<RawElement> = None;
|
|
|
|
while cursor < bytes.len() {
|
|
if bytes[cursor] == b'<' {
|
|
if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"<!--[if") {
|
|
let Some(end) = find_bytes(bytes, cursor + 4, 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"<!--") {
|
|
let Some(end) = find_bytes(bytes, cursor + 4, 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<usize> {
|
|
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<usize> {
|
|
let mut quote: Option<u8> = 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");
|