use crate::Result;
const BLOCK_ELEMENTS: &[&str] = &[
"address",
"article",
"aside",
"blockquote",
"body",
"br",
"canvas",
"caption",
"col",
"colgroup",
"dd",
"details",
"div",
"dl",
"dt",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hr",
"html",
"legend",
"li",
"main",
"meta",
"nav",
"ol",
"option",
"p",
"pre",
"script",
"section",
"style",
"summary",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"title",
"tr",
"ul",
];
const RAW_TEXT_ELEMENTS: &[&str] =
&["pre", "textarea", "script", "style"];
pub(crate) fn minify(input: &str) -> Result<String> {
let bytes = input.as_bytes();
let mut out = String::with_capacity(input.len());
let mut i = 0usize;
let mut prev_tag: Option<String> = None;
while i < bytes.len() {
if bytes[i] == b'<' {
if input[i..].starts_with("<!--") {
let is_conditional = input[i..].starts_with("<!--[if");
let end = input[i..]
.find("-->")
.map_or(bytes.len(), |e| i + e + 3);
if is_conditional {
out.push_str(&input[i..end]);
}
i = end;
continue;
}
let Some(rel_end) = input[i..].find('>') else {
out.push_str(&input[i..]);
break;
};
let end = i + rel_end + 1;
let tag = &input[i..end];
out.push_str(tag);
let name = tag_name(tag);
if let Some(ref n) = name {
if RAW_TEXT_ELEMENTS.contains(&n.as_str())
&& !tag.starts_with("</")
&& !tag.ends_with("/>")
{
let close = format!("</{n}");
if let Some(rel) = input[end..].find(&close) {
out.push_str(&input[end..end + rel]);
i = end + rel;
prev_tag = name;
continue;
}
}
}
prev_tag = name;
i = end;
continue;
}
let next = input[i..].find('<').map_or(bytes.len(), |n| i + n);
let text = &input[i..next];
i = next;
if text.trim().is_empty() {
let next_tag = tag_name_at(input, i);
let droppable = is_block(prev_tag.as_deref())
|| is_block(next_tag.as_deref());
if !droppable && !text.is_empty() {
out.push(' ');
}
continue;
}
out.push_str(&collapse_whitespace(text));
}
Ok(out)
}
fn collapse_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_ws = false;
for ch in text.chars() {
if ch.is_whitespace() {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
out.push(ch);
in_ws = false;
}
}
out
}
fn tag_name(tag: &str) -> Option<String> {
let body = tag
.trim_start_matches('<')
.trim_start_matches('/')
.trim_end_matches('>')
.trim_end_matches('/');
if body.starts_with('!') || body.starts_with('?') {
return None; }
let name: String = body
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if name.is_empty() {
None
} else {
Some(name.to_ascii_lowercase())
}
}
fn tag_name_at(input: &str, idx: usize) -> Option<String> {
if !input[idx..].starts_with('<') {
return None;
}
let end = input[idx..].find('>')? + idx + 1;
tag_name(&input[idx..end])
}
fn is_block(name: Option<&str>) -> bool {
name.map_or(true, |n| BLOCK_ELEMENTS.contains(&n))
}
#[cfg(test)]
mod tests {
use super::minify;
fn m(s: &str) -> String {
minify(s).expect("minify")
}
#[test]
fn collapses_whitespace_between_block_elements() {
assert_eq!(
m("<html> <body> <p>Test</p> </body> </html>"),
"<html><body><p>Test</p></body></html>"
);
}
#[test]
fn keeps_whitespace_between_inline_elements() {
assert_eq!(
m("<span>a</span> <span>b</span>"),
"<span>a</span> <span>b</span>"
);
assert_eq!(
m("<em>x</em> <em>y</em>"),
"<em>x</em> <em>y</em>"
);
}
#[test]
fn drops_comments_but_keeps_conditional_ones() {
assert_eq!(
m("<html><!-- note --><body><p>T</p></body></html>"),
"<html><body><p>T</p></body></html>"
);
let cond = "<!--[if IE]><p>old</p><![endif]-->";
assert!(
m(cond).contains("[if IE]"),
"conditional comments are markup and must survive"
);
}
#[test]
fn preserves_raw_text_bodies_byte_for_byte() {
let pre = "<pre> two spaces\n and a newline</pre>";
assert_eq!(m(pre), pre, "pre content must not be touched");
let script = "<script>const a = 1; const b = 2;</script>";
assert_eq!(m(script), script, "script bodies pass through");
let style = "<style>body { color : red ; }</style>";
assert_eq!(m(style), style, "style bodies pass through");
let ta = "<textarea> keep me </textarea>";
assert_eq!(m(ta), ta, "textarea content is user-visible");
}
#[test]
fn preserves_utf8_and_in_text_spacing() {
assert_eq!(
m("<html><body><p>Test 你好 🦀</p></body></html>"),
"<html><body><p>Test 你好 🦀</p></body></html>"
);
}
#[test]
fn collapses_runs_inside_text() {
assert_eq!(m("<p>a b</p>"), "<p>a b</p>");
}
#[test]
fn leaves_entities_alone() {
let s = "<div><Special> & Characters</div>";
assert_eq!(m(s), s);
}
#[test]
fn leaves_attributes_untouched() {
let s = r#"<a href="x.html" class="a b" data-x='1'>t</a>"#;
assert_eq!(m(s), s, "attribute text is never rewritten");
}
#[test]
fn keeps_the_doctype() {
let s = "<!DOCTYPE html><html><body><p>x</p></body></html>";
assert_eq!(m(s), s);
}
#[test]
fn handles_unterminated_tag_without_panicking() {
assert_eq!(m("<p>ok</p><div"), "<p>ok</p><div");
}
#[test]
fn empty_and_whitespace_only_inputs() {
assert_eq!(m(""), "");
assert_eq!(m(" \n "), "");
}
#[test]
fn actually_reduces_size_on_a_realistic_document() {
let src =
"<html>\n <head>\n <title>T</title>\n </head>\n \
<body>\n <!-- hi -->\n <p>Hello</p>\n \
</body>\n</html>";
let out = m(src);
assert!(
out.len() < src.len(),
"{} !< {}",
out.len(),
src.len()
);
assert!(!out.contains("<!--"), "comments removed");
}
}