use std::borrow::Cow;
use textlane_core::hast::{Element, Node, Text};
use textlane_core::{Allocator, ArenaVec};
use crate::helpers::phrasing::is_embedded;
use crate::helpers::value::{find_value, is_truthy};
const STACK_RED_ZONE: usize = 64 * 1024;
const STACK_GROWTH: usize = 1024 * 1024;
pub(crate) fn minify_hast<'a>(node: &mut Node<'a>, newlines: bool, alloc: &'a Allocator) {
let mut minifier = Minifier {
newlines,
alloc,
boundaries: vec![],
sizes: vec![],
};
minifier.measure(node);
let state = State {
whitespace: Whitespace::Normal,
before: false,
after: false,
};
minifier.minify(node, 0, state);
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Whitespace {
Normal,
Pre,
PreWrap,
Nowrap,
}
#[derive(Clone, Copy)]
struct State {
whitespace: Whitespace,
before: bool,
after: bool,
}
#[derive(Default)]
struct Outcome {
ignore: bool,
strip_at_start: bool,
remove: bool,
}
struct Minifier<'a> {
newlines: bool,
alloc: &'a Allocator,
boundaries: Vec<Option<bool>>,
sizes: Vec<usize>,
}
impl<'a> Minifier<'a> {
fn measure(&mut self, node: &Node<'_>) {
let index = self.boundaries.len();
self.boundaries.push(None);
self.sizes.push(1);
let children = children_of(node);
let mut first = None;
for child in children.unwrap_or_default() {
let child_index = self.boundaries.len();
stacker::maybe_grow(STACK_RED_ZONE, STACK_GROWTH, || self.measure(child));
first = first.or(self.boundaries[child_index]);
}
self.sizes[index] = self.boundaries.len() - index;
self.boundaries[index] = infer_boundary(node).or_else(|| {
(children.is_some() && !is_skippable(node))
.then_some(first)
.flatten()
});
}
fn minify(&self, node: &mut Node<'a>, index: usize, state: State) -> Outcome {
match node {
Node::Root(root) => {
let settings = State {
before: true,
after: true,
..state
};
self.all(&mut root.children, index, settings)
}
Node::Element(element) => {
let mut settings = state;
if is_blocklike(element.tag_name) {
settings.before = true;
settings.after = true;
}
settings.whitespace = infer_whitespace(element, state.whitespace);
self.all(&mut element.children, index, settings)
}
Node::Text(text) => match state.whitespace {
Whitespace::Normal => self.minify_text(text, state),
Whitespace::Nowrap => {
if let Cow::Owned(value) = collapse(text.value, self.newlines) {
text.value = self.alloc.alloc_str(&value);
}
Outcome::default()
}
Whitespace::Pre | Whitespace::PreWrap => Outcome::default(),
},
Node::Comment(_) | Node::Doctype(_) => Outcome {
ignore: true,
..Outcome::default()
},
Node::Raw(_)
| Node::Unknown(_)
| Node::MdxJsxFlowElement(_)
| Node::MdxJsxTextElement(_)
| Node::MdxFlowExpression(_)
| Node::MdxTextExpression(_)
| Node::MdxjsEsm(_) => Outcome::default(),
}
}
fn minify_text(&self, text: &mut Text<'a>, state: State) -> Outcome {
let value = collapse(text.value, self.newlines);
let bytes = value.as_bytes();
let mut result = Outcome::default();
let mut start = 0;
let mut end = bytes.len();
if state.before && bytes.first().is_some_and(is_removable) {
start += 1;
}
if start != end && bytes.get(end - 1).is_some_and(is_removable) {
if state.after {
end -= 1;
} else {
result.strip_at_start = true;
}
}
if start == end {
result.remove = true;
} else if start != 0 || end != bytes.len() || matches!(value, Cow::Owned(_)) {
text.value = self.alloc.alloc_str(&value[start..end]);
}
result
}
fn all(
&self,
children: &mut ArenaVec<'a, Node<'a>>,
index: usize,
state: State,
) -> Outcome {
let mut indices = Vec::with_capacity(children.len());
let mut next = index + 1;
for _ in 0..children.len() {
indices.push(next);
next += self.sizes[next];
}
let mut collapsable = vec![None; children.len()];
let mut found = None;
for (child, index) in indices.iter().enumerate().rev() {
collapsable[child] = found;
found = self.boundaries[*index].or(found);
}
let mut before = state.before;
let after = state.after;
let mut removed = vec![false; children.len()];
let mut last_kept = None;
for child in 0..children.len() {
let child_state = State {
after: collapsable[child].unwrap_or(after),
before,
..state
};
let result = stacker::maybe_grow(STACK_RED_ZONE, STACK_GROWTH, || {
self.minify(&mut children[child], indices[child], child_state)
});
let current = if result.remove {
removed[child] = true;
last_kept
} else {
if !result.ignore {
before = result.strip_at_start;
}
last_kept = Some(child);
last_kept
};
if current.is_some_and(|current| is_content(&children[current])) {
before = false;
}
}
if removed.contains(&true) {
let mut removed = removed.into_iter();
children.retain(|_| !removed.next().unwrap_or(false));
}
Outcome {
strip_at_start: before || after,
..Outcome::default()
}
}
}
fn children_of<'n, 'a>(node: &'n Node<'a>) -> Option<&'n [Node<'a>]> {
match node {
Node::Root(root) => Some(&root.children),
Node::Element(element) => Some(&element.children),
_ => None,
}
}
fn infer_boundary(node: &Node<'_>) -> Option<bool> {
match node {
Node::Element(_) if is_content(node) => Some(false),
Node::Element(element) if is_blocklike(element.tag_name) => Some(true),
Node::Element(_) => None,
Node::Text(text) if !is_whitespace(text.value) => Some(false),
Node::Text(_) | Node::Comment(_) | Node::Doctype(_) => None,
Node::Root(_)
| Node::Raw(_)
| Node::Unknown(_)
| Node::MdxJsxFlowElement(_)
| Node::MdxJsxTextElement(_)
| Node::MdxFlowExpression(_)
| Node::MdxTextExpression(_)
| Node::MdxjsEsm(_) => Some(false),
}
}
fn is_content(node: &Node<'_>) -> bool {
match node {
Node::Element(element) => {
is_embedded(element)
|| matches!(element.tag_name, "button" | "input" | "select" | "textarea")
}
_ => false,
}
}
fn is_skippable(node: &Node<'_>) -> bool {
match node {
Node::Element(element) => {
is_truthy(find_value(element, "hidden"))
|| matches!(
element.tag_name,
"area"
| "base"
| "basefont"
| "dialog"
| "datalist"
| "head"
| "link"
| "meta"
| "noembed"
| "noframes"
| "param"
| "rp"
| "script"
| "source"
| "style"
| "template"
| "track"
| "title"
)
}
Node::Comment(_) | Node::Doctype(_) => true,
_ => false,
}
}
fn is_blocklike(tag_name: &str) -> bool {
matches!(
tag_name,
"address"
| "article"
| "aside"
| "blockquote"
| "body"
| "br"
| "caption"
| "center"
| "col"
| "colgroup"
| "dd"
| "dialog"
| "dir"
| "div"
| "dl"
| "dt"
| "figcaption"
| "figure"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "head"
| "header"
| "hgroup"
| "hr"
| "html"
| "legend"
| "li"
| "listing"
| "main"
| "menu"
| "nav"
| "ol"
| "optgroup"
| "option"
| "p"
| "plaintext"
| "pre"
| "section"
| "summary"
| "table"
| "tbody"
| "td"
| "tfoot"
| "th"
| "thead"
| "tr"
| "ul"
| "wbr"
| "xmp"
)
}
fn infer_whitespace(element: &Element<'_>, inherited: Whitespace) -> Whitespace {
match element.tag_name {
"listing" | "plaintext" | "script" | "style" | "xmp" => Whitespace::Pre,
"nobr" => Whitespace::Nowrap,
"pre" if is_truthy(find_value(element, "wrap")) => Whitespace::PreWrap,
"pre" => Whitespace::Pre,
"td" | "th" if is_truthy(find_value(element, "noWrap")) => Whitespace::Nowrap,
"textarea" => Whitespace::PreWrap,
_ => inherited,
}
}
fn collapse(value: &str, newlines: bool) -> Cow<'_, str> {
let is_space = |byte: &u8| matches!(byte, b'\t' | b'\n' | 0x0B | 0x0C | b'\r' | b' ');
let bytes = value.as_bytes();
let mut output = String::new();
let mut copied = 0;
let mut index = 0;
while index < bytes.len() {
if !is_space(&bytes[index]) {
index += 1;
continue;
}
let start = index;
while index < bytes.len() && is_space(&bytes[index]) {
index += 1;
}
let run = &value[start..index];
let replacement = if newlines {
match run.find(['\n', '\r']) {
Some(at) if run[at..].starts_with("\r\n") => "\r\n",
Some(at) => &run[at..at + 1],
None => " ",
}
} else {
" "
};
if run != replacement {
output.push_str(&value[copied..start]);
output.push_str(replacement);
copied = index;
}
}
if copied == 0 && output.is_empty() {
return Cow::Borrowed(value);
}
output.push_str(&value[copied..]);
Cow::Owned(output)
}
fn is_removable(byte: &u8) -> bool {
*byte == b' ' || *byte == b'\n'
}
fn is_whitespace(value: &str) -> bool {
value
.bytes()
.all(|byte| matches!(byte, b' ' | b'\t' | b'\n' | 0x0C | b'\r'))
}