Skip to main content

quarb_html/
lib.rs

1//! HTML adapter for Quarb.
2//!
3//! Maps an HTML document onto the arbor model, parsing with
4//! html5ever (via `scraper`) so malformed markup is handled per the
5//! HTML5 standard.
6//!
7//! - Elements are the nodes; text and comment nodes are not navigated
8//!   (text is exposed as a projection instead).
9//! - A node's *name* is its tag (`html`, `body`, `div`, `h1`, `a`),
10//!   so `/html/body//p` navigates by element type. The document root
11//!   is unnamed; the `<html>` element is its single child.
12//! - A node's *traits* are structural classes: `<block>` or
13//!   `<inline>`, plus `<heading>` for `h1`–`h6` and `<link>` for an
14//!   anchor with an `href`.
15//! - Attributes are properties: `::href`, `::class`, `::id`. The
16//!   default projection (`::`) and `::text` are the element's text
17//!   content; `;;;tag`, `;;;id`, `;;;classes`, and any `;;;attr`
18//!   expose metadata.
19//! - An anchor's fragment `href` resolves: `::href~>` follows
20//!   `#section` to the element with `id="section"`.
21
22use quarb::{AstAdapter, NodeId, Value};
23use scraper::{ElementRef, Html};
24use std::collections::HashMap;
25
26struct Node {
27    /// The tag name; `None` for the document root.
28    tag: Option<String>,
29    attrs: Vec<(String, String)>,
30    /// The element's text content (all descendant text, concatenated).
31    text: String,
32    parent: Option<NodeId>,
33    children: Vec<NodeId>,
34}
35
36impl Node {
37    fn attr(&self, name: &str) -> Option<&str> {
38        self.attrs
39            .iter()
40            .find(|(k, _)| k == name)
41            .map(|(_, v)| v.as_str())
42    }
43}
44
45/// A Quarb adapter over a parsed HTML document.
46pub struct HtmlAdapter {
47    nodes: Vec<Node>,
48    /// `id` attribute value → the element carrying it.
49    ids: HashMap<String, NodeId>,
50    root: NodeId,
51}
52
53impl HtmlAdapter {
54    /// Parse `html` and build the adapter.
55    pub fn parse(html: &str) -> Self {
56        let document = Html::parse_document(html);
57        let mut nodes = vec![Node {
58            tag: None,
59            attrs: Vec::new(),
60            text: String::new(),
61            parent: None,
62            children: Vec::new(),
63        }];
64        let mut ids = HashMap::new();
65        let root = NodeId(0);
66
67        let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
68        nodes[0].text = nodes[html_node.0 as usize].text.clone();
69        nodes[0].children = vec![html_node];
70
71        HtmlAdapter { nodes, ids, root }
72    }
73
74    /// A locator path to `node`, like `/html/body/div[2]/p`, for
75    /// rendering. A `[n]` index is added only to disambiguate same-tag
76    /// siblings.
77    pub fn locator(&self, node: NodeId) -> String {
78        let mut segments = Vec::new();
79        let mut cur = Some(node);
80        while let Some(id) = cur {
81            let n = &self.nodes[id.0 as usize];
82            if let Some(tag) = &n.tag {
83                segments.push(self.segment(id, tag));
84            }
85            cur = n.parent;
86        }
87        segments.reverse();
88        format!("/{}", segments.join("/"))
89    }
90
91    fn segment(&self, node: NodeId, tag: &str) -> String {
92        let Some(parent) = self.nodes[node.0 as usize].parent else {
93            return tag.to_string();
94        };
95        let siblings = &self.nodes[parent.0 as usize].children;
96        let same_tag: Vec<NodeId> = siblings
97            .iter()
98            .copied()
99            .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
100            .collect();
101        if same_tag.len() > 1 {
102            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
103            format!("{tag}[{n}]")
104        } else {
105            tag.to_string()
106        }
107    }
108}
109
110/// Intern the element `el` (child of `parent`), returning its node id.
111/// Text and comment children are skipped as nodes but captured in the
112/// element's text content.
113///
114/// Iterative (an explicit work stack rather than self-recursion) so
115/// pathologically deep markup cannot overflow the call stack; html5ever
116/// parses such input iteratively, so a recursive interning pass was the
117/// only remaining depth limit. Elements are interned in document
118/// (pre-order) order, so node ids, child ordering, and the
119/// first-occurrence `ids` map are identical to the recursive form.
120fn build(
121    el: ElementRef,
122    parent: Option<NodeId>,
123    nodes: &mut Vec<Node>,
124    ids: &mut HashMap<String, NodeId>,
125) -> NodeId {
126    let root_this = NodeId(nodes.len() as u64);
127    // Work stack of (element, parent id), popped in pre-order.
128    let mut stack = vec![(el, parent)];
129
130    while let Some((el, parent)) = stack.pop() {
131        let this = NodeId(nodes.len() as u64);
132
133        let tag = el.value().name().to_string();
134        let attrs: Vec<(String, String)> = el
135            .value()
136            .attrs()
137            .map(|(k, v)| (k.to_string(), v.to_string()))
138            .collect();
139        let text: String = el.text().collect();
140
141        if let Some(id) = el.value().id() {
142            ids.entry(id.to_string()).or_insert(this);
143        }
144
145        nodes.push(Node {
146            tag: Some(tag),
147            attrs,
148            text,
149            parent,
150            children: Vec::new(),
151        });
152
153        // Record the child under its parent as it is interned; because
154        // siblings are pushed reversed below, they pop in document order
155        // and so append in document order.
156        if let Some(p) = parent {
157            nodes[p.0 as usize].children.push(this);
158        }
159
160        // Push element children in reverse so the leftmost is popped
161        // (and interned) first — a pre-order DFS.
162        let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
163        for child in children.into_iter().rev() {
164            stack.push((child, Some(this)));
165        }
166    }
167
168    root_this
169}
170
171/// Block-level HTML element tags.
172const BLOCK: &[&str] = &[
173    "address",
174    "article",
175    "aside",
176    "blockquote",
177    "body",
178    "details",
179    "dialog",
180    "dd",
181    "div",
182    "dl",
183    "dt",
184    "fieldset",
185    "figcaption",
186    "figure",
187    "footer",
188    "form",
189    "h1",
190    "h2",
191    "h3",
192    "h4",
193    "h5",
194    "h6",
195    "header",
196    "hgroup",
197    "hr",
198    "html",
199    "li",
200    "main",
201    "nav",
202    "ol",
203    "p",
204    "pre",
205    "section",
206    "table",
207    "ul",
208];
209
210fn is_heading(tag: &str) -> bool {
211    matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
212}
213
214impl AstAdapter for HtmlAdapter {
215    fn root(&self) -> NodeId {
216        self.root
217    }
218
219    fn children(&self, node: NodeId) -> Vec<NodeId> {
220        self.nodes[node.0 as usize].children.clone()
221    }
222
223    fn name(&self, node: NodeId) -> Option<String> {
224        self.nodes[node.0 as usize].tag.clone()
225    }
226
227    fn parent(&self, node: NodeId) -> Option<NodeId> {
228        self.nodes[node.0 as usize].parent
229    }
230
231    /// Structural traits: `<block>` or `<inline>`, plus `<heading>`
232    /// for `h1`–`h6` and `<link>` for an anchor with an `href`.
233    fn traits(&self, node: NodeId) -> Vec<String> {
234        let n = &self.nodes[node.0 as usize];
235        let Some(tag) = &n.tag else {
236            return Vec::new();
237        };
238        let mut out = Vec::new();
239        if BLOCK.contains(&tag.as_str()) {
240            out.push("block".to_string());
241        } else {
242            out.push("inline".to_string());
243        }
244        if is_heading(tag) {
245            out.push("heading".to_string());
246        }
247        if tag == "a" && n.attr("href").is_some() {
248            out.push("link".to_string());
249        }
250        out
251    }
252
253    /// `::text` is the element's text content; any other name is an
254    /// attribute value.
255    /// Attributes, by name (`::href`). Element text is the bare
256    /// projection (`::`) — no attribute name is shadowed.
257    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
258        self.nodes[node.0 as usize]
259            .attr(name)
260            .map(|v| Value::Str(v.to_string()))
261    }
262
263    /// The default projection of an element is its text content.
264    fn default_value(&self, node: NodeId) -> Option<Value> {
265        Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
266    }
267
268    /// `;;;tag`, `;;;classes` (the class attribute, split), and
269    /// `;;;n-attrs` — facts about the element, never its data:
270    /// attributes are properties (`::href`), text is the bare
271    /// projection.
272    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
273        let n = &self.nodes[node.0 as usize];
274        match key {
275            "tag" => n.tag.clone().map(Value::Str),
276            "classes" => n.attr("class").map(|c| {
277                Value::List(
278                    c.split_whitespace()
279                        .map(|s| Value::Str(s.to_string()))
280                        .collect(),
281                )
282            }),
283            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
284            _ => None,
285        }
286    }
287
288    /// Follow an attribute that is a fragment reference (`#section`) to
289    /// the element with that `id`. Used by an anchor's `::href~>`.
290    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
291        let value = self.nodes[node.0 as usize].attr(property)?;
292        let fragment = value.strip_prefix('#')?;
293        self.ids.get(fragment).copied()
294    }
295}