seam-skeleton 0.5.38

HTML skeleton extraction pipeline for SeamJS CLI
Documentation
/* src/cli/skeleton/src/ctr_check/parse.rs */

// Semantic HTML parser for CTR equivalence checking.
// Produces a CtrNode tree with BTreeMap attrs (auto-sorted keys).
// Filters comments, data scripts, and resource hint links
// during parse so downstream stages see only semantic content.

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",
];

/// Parse HTML into a semantic CtrNode tree.
/// Comments are filtered, adjacent text nodes merged, and
/// data script + resource hint links are skipped.
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'<' {
			// Closing tag
			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;
					}
				}
				// Unexpected closing tag; consume and return
				while *pos < bytes.len() && bytes[*pos] != b'>' {
					*pos += 1;
				}
				if *pos < bytes.len() {
					*pos += 1;
				}
				return nodes;
			}

			// Comment — parse but do not add to tree
			if bytes[*pos..].starts_with(b"<!--") {
				skip_comment(bytes, pos);
				continue;
			}

			// Element
			if let Some(node) = parse_element(bytes, pos, data_id) {
				nodes.push(node);
			}
		} else {
			// Text
			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; // skip "<!--"
	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();
}

/// Parse an element, returning None if it should be filtered (script/resource hint).
fn parse_element(bytes: &[u8], pos: &mut usize, data_id: &str) -> Option<CtrNode> {
	// Skip '<'
	*pos += 1;
	let tag_start = *pos;

	// Read tag name
	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();

	// Read raw attribute string (quote-aware scanning for '>' or '/>')
	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; // skip '/>'
	} else if *pos < bytes.len() {
		*pos += 1; // skip '>'
	}

	let is_void = VOID_ELEMENTS.contains(&tag.as_str());

	// Parse children for non-void, non-self-closed elements
	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()
	};

	// Filter: data script
	if tag == "script" && attrs.get("id").is_some_and(|v| v == data_id) {
		return None;
	}

	// Filter: resource hint links
	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;
			}
		}
	}

	// Recurse merge into children (already done above, but if void/self-closed it's empty)
	if !children.is_empty() {
		// Already merged above
	}

	Some(CtrNode::Element { tag, attrs, children })
}

/// Parse raw attribute string into BTreeMap.
/// Handles key="value", key='value', and bare boolean attrs.
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() {
		// Skip whitespace
		while i < bytes.len() && bytes[i].is_ascii_whitespace() {
			i += 1;
		}
		if i >= bytes.len() {
			break;
		}

		// Read key
		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();

		// Check for '='
		if i < bytes.len() && bytes[i] == b'=' {
			i += 1; // skip '='
			if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
				let quote = bytes[i];
				i += 1; // skip opening quote
				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; // skip closing quote
				}
				map.insert(key, value);
			} else {
				// Unquoted value (rare in renderToString output)
				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 {
			// Bare boolean attribute
			map.insert(key, String::new());
		}
	}

	map
}

/// Merge adjacent Text nodes in a children list.
/// Needed after comment filtering: "by " + [comment] + "Alice" -> "by Alice".
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);
			}
			// Don't increment i — check if the merged node can merge with the next
		} 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>"#,
		);
		// preload filtered, canonical preserved
		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() {
		// Comment between text nodes gets filtered, texts merge
		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"),
		}
	}
}