use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub(super) enum CtrNode {
Element { tag: String, attrs: BTreeMap<String, String>, children: Vec<CtrNode> },
Text(String),
}
const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr",
];
pub(super) fn parse_ctr_tree(html: &str, data_id: &str) -> Vec<CtrNode> {
let bytes = html.as_bytes();
let mut pos = 0;
let mut nodes = parse_nodes(bytes, &mut pos, None, data_id);
merge_adjacent_text(&mut nodes);
nodes
}
fn parse_nodes(
bytes: &[u8],
pos: &mut usize,
parent_tag: Option<&str>,
data_id: &str,
) -> Vec<CtrNode> {
let mut nodes = Vec::new();
while *pos < bytes.len() {
if bytes[*pos] == b'<' {
if *pos + 1 < bytes.len() && bytes[*pos + 1] == b'/' {
if let Some(parent) = parent_tag {
let expected = format!("</{parent}>");
if bytes[*pos..].starts_with(expected.as_bytes()) {
*pos += expected.len();
return nodes;
}
}
while *pos < bytes.len() && bytes[*pos] != b'>' {
*pos += 1;
}
if *pos < bytes.len() {
*pos += 1;
}
return nodes;
}
if bytes[*pos..].starts_with(b"<!--") {
skip_comment(bytes, pos);
continue;
}
if let Some(node) = parse_element(bytes, pos, data_id) {
nodes.push(node);
}
} else {
let start = *pos;
while *pos < bytes.len() && bytes[*pos] != b'<' {
*pos += 1;
}
let text = std::str::from_utf8(&bytes[start..*pos]).expect("valid UTF-8 from HTML source");
if !text.is_empty() {
nodes.push(CtrNode::Text(text.to_string()));
}
}
}
nodes
}
fn skip_comment(bytes: &[u8], pos: &mut usize) {
*pos += 4; while *pos + 2 < bytes.len() {
if bytes[*pos] == b'-' && bytes[*pos + 1] == b'-' && bytes[*pos + 2] == b'>' {
*pos += 3;
return;
}
*pos += 1;
}
*pos = bytes.len();
}
fn parse_element(bytes: &[u8], pos: &mut usize, data_id: &str) -> Option<CtrNode> {
*pos += 1;
let tag_start = *pos;
while *pos < bytes.len() && bytes[*pos] != b' ' && bytes[*pos] != b'>' && bytes[*pos] != b'/' {
*pos += 1;
}
let tag = std::str::from_utf8(&bytes[tag_start..*pos])
.expect("valid UTF-8 from HTML source")
.to_lowercase();
let attrs_start = *pos;
let mut in_quote: Option<u8> = None;
let mut self_closed = false;
loop {
if *pos >= bytes.len() {
break;
}
match in_quote {
Some(q) => {
if bytes[*pos] == q {
in_quote = None;
}
*pos += 1;
}
None => {
if bytes[*pos] == b'"' || bytes[*pos] == b'\'' {
in_quote = Some(bytes[*pos]);
*pos += 1;
} else if bytes[*pos] == b'/' && *pos + 1 < bytes.len() && bytes[*pos + 1] == b'>' {
self_closed = true;
break;
} else if bytes[*pos] == b'>' {
break;
} else {
*pos += 1;
}
}
}
}
let attrs_raw =
std::str::from_utf8(&bytes[attrs_start..*pos]).expect("valid UTF-8 from HTML source");
let attrs = parse_attrs(attrs_raw);
if self_closed {
*pos += 2; } else if *pos < bytes.len() {
*pos += 1; }
let is_void = VOID_ELEMENTS.contains(&tag.as_str());
let children = if !self_closed && !is_void {
let mut kids = parse_nodes(bytes, pos, Some(&tag), data_id);
merge_adjacent_text(&mut kids);
kids
} else {
Vec::new()
};
if tag == "script" && attrs.get("id").is_some_and(|v| v == data_id) {
return None;
}
if tag == "link" {
if attrs.contains_key("data-precedence") {
return None;
}
if let Some(rel) = attrs.get("rel") {
let rel_lower = rel.to_lowercase();
if rel_lower == "preload" || rel_lower == "dns-prefetch" || rel_lower == "preconnect" {
return None;
}
}
}
if !children.is_empty() {
}
Some(CtrNode::Element { tag, attrs, children })
}
fn parse_attrs(raw: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let key_start = i;
while i < bytes.len()
&& bytes[i] != b'='
&& !bytes[i].is_ascii_whitespace()
&& bytes[i] != b'>'
&& bytes[i] != b'/'
{
i += 1;
}
if i == key_start {
break;
}
let key =
std::str::from_utf8(&bytes[key_start..i]).expect("valid UTF-8 from HTML source").to_string();
if i < bytes.len() && bytes[i] == b'=' {
i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
i += 1; let val_start = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
let value = std::str::from_utf8(&bytes[val_start..i])
.expect("valid UTF-8 from HTML source")
.to_string();
if i < bytes.len() {
i += 1; }
map.insert(key, value);
} else {
let val_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
let value = std::str::from_utf8(&bytes[val_start..i])
.expect("valid UTF-8 from HTML source")
.to_string();
map.insert(key, value);
}
} else {
map.insert(key, String::new());
}
}
map
}
fn merge_adjacent_text(nodes: &mut Vec<CtrNode>) {
let mut i = 0;
while i + 1 < nodes.len() {
if let (CtrNode::Text(_), CtrNode::Text(_)) = (&nodes[i], &nodes[i + 1]) {
let next = nodes.remove(i + 1);
if let CtrNode::Text(ref mut a) = nodes[i]
&& let CtrNode::Text(b) = next
{
a.push_str(&b);
}
} else {
i += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(html: &str) -> Vec<CtrNode> {
parse_ctr_tree(html, "__data")
}
#[test]
fn parse_simple_element() {
let nodes = tree(r#"<div class="red">hello</div>"#);
assert_eq!(nodes.len(), 1);
match &nodes[0] {
CtrNode::Element { tag, attrs, children } => {
assert_eq!(tag, "div");
assert_eq!(attrs.get("class").unwrap(), "red");
assert_eq!(children.len(), 1);
assert_eq!(children[0], CtrNode::Text("hello".to_string()));
}
_ => panic!("expected Element"),
}
}
#[test]
fn parse_multiple_attrs_sorted() {
let nodes = tree(r#"<img src="x.png" alt="photo" class="thumb"/>"#);
match &nodes[0] {
CtrNode::Element { attrs, .. } => {
let keys: Vec<&String> = attrs.keys().collect();
assert_eq!(keys, vec!["alt", "class", "src"]);
}
_ => panic!("expected Element"),
}
}
#[test]
fn parse_filters_comments() {
let nodes = tree("<!--$--><div>ok</div><!--/$-->");
assert_eq!(nodes.len(), 1);
match &nodes[0] {
CtrNode::Element { tag, .. } => assert_eq!(tag, "div"),
_ => panic!("expected Element"),
}
}
#[test]
fn parse_filters_data_script() {
let nodes = tree(r#"<p>hello</p><script id="__data" type="application/json">{"x":1}</script>"#);
assert_eq!(nodes.len(), 1);
match &nodes[0] {
CtrNode::Element { tag, .. } => assert_eq!(tag, "p"),
_ => panic!("expected Element"),
}
}
#[test]
fn parse_filters_resource_hints() {
let nodes = tree(
r#"<link rel="preload" as="image" href="x.png"><link rel="canonical" href="/page"><div>ok</div>"#,
);
assert_eq!(nodes.len(), 2);
match &nodes[0] {
CtrNode::Element { tag, attrs, .. } => {
assert_eq!(tag, "link");
assert_eq!(attrs.get("rel").unwrap(), "canonical");
}
_ => panic!("expected link Element"),
}
}
#[test]
fn parse_merges_adjacent_text() {
let nodes = tree("<p>by <!-- -->Alice</p>");
match &nodes[0] {
CtrNode::Element { children, .. } => {
assert_eq!(children.len(), 1);
assert_eq!(children[0], CtrNode::Text("by Alice".to_string()));
}
_ => panic!("expected Element"),
}
}
#[test]
fn parse_void_elements() {
let nodes = tree(r#"<br><img src="x"><input type="text"/>"#);
assert_eq!(nodes.len(), 3);
for node in &nodes {
match node {
CtrNode::Element { children, .. } => assert!(children.is_empty()),
_ => panic!("expected Element"),
}
}
}
#[test]
fn parse_bare_boolean_attr() {
let nodes = tree("<input disabled/>");
match &nodes[0] {
CtrNode::Element { attrs, .. } => {
assert_eq!(attrs.get("disabled").unwrap(), "");
}
_ => panic!("expected Element"),
}
}
#[test]
fn parse_quoted_attr_with_angle_bracket() {
let nodes = tree(r#"<div data-x="a>b">ok</div>"#);
match &nodes[0] {
CtrNode::Element { attrs, children, .. } => {
assert_eq!(attrs.get("data-x").unwrap(), "a>b");
assert_eq!(children.len(), 1);
}
_ => panic!("expected Element"),
}
}
}