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 (`::`) is the element's text content;
17//!   `::::tag`, `::::classes`, `::::attrs`, and `::::n-attrs`
18//!   expose adapter 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    /// The document's own URL, when the mount knows it — the base
52    /// a relative `href` joins against for `::href-->` across
53    /// documents. `None` for pasted or fileless html: only
54    /// absolute hrefs reference out then.
55    document_url: Option<url::Url>,
56}
57
58impl HtmlAdapter {
59    /// Does `node`'s `rel` admit the relation `hint`? No hint and
60    /// the `*` wildcard admit everything; a named hint needs the
61    /// space-separated `rel` list to contain it (`rel="nofollow
62    /// noopener"` answers to either word). An element without a
63    /// `rel` answers only the wildcard forms — the spec's
64    /// `-->stylesheet` reads "only the stylesheet relation".
65    fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
66        match hint {
67            None | Some("*") => true,
68            Some(h) => self.nodes[node.0 as usize]
69                .attr("rel")
70                .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
71        }
72    }
73
74    /// Declare the document's own URL — the base a relative `href`
75    /// joins against when `::href-->` reaches across documents.
76    pub fn set_document_url(&mut self, url: &str) {
77        self.document_url = url::Url::parse(url).ok();
78    }
79
80    /// Parse `html` and build the adapter.
81    pub fn parse(html: &str) -> Self {
82        let document = Html::parse_document(html);
83        let mut nodes = vec![Node {
84            tag: None,
85            attrs: Vec::new(),
86            text: String::new(),
87            parent: None,
88            children: Vec::new(),
89        }];
90        let mut ids = HashMap::new();
91        let root = NodeId(0);
92
93        let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
94        nodes[0].text = nodes[html_node.0 as usize].text.clone();
95        nodes[0].children = vec![html_node];
96
97        HtmlAdapter {
98            nodes,
99            ids,
100            root,
101            document_url: None,
102        }
103    }
104
105    /// A locator path to `node`, like `/html/body/div[2]/p`, for
106    /// rendering. A `[n]` index is added only to disambiguate same-tag
107    /// siblings.
108    pub fn locator(&self, node: NodeId) -> String {
109        let mut segments = Vec::new();
110        let mut cur = Some(node);
111        while let Some(id) = cur {
112            let n = &self.nodes[id.0 as usize];
113            if let Some(tag) = &n.tag {
114                segments.push(self.segment(id, tag));
115            }
116            cur = n.parent;
117        }
118        segments.reverse();
119        format!("/{}", segments.join("/"))
120    }
121
122    fn segment(&self, node: NodeId, tag: &str) -> String {
123        let Some(parent) = self.nodes[node.0 as usize].parent else {
124            return tag.to_string();
125        };
126        let siblings = &self.nodes[parent.0 as usize].children;
127        let same_tag: Vec<NodeId> = siblings
128            .iter()
129            .copied()
130            .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
131            .collect();
132        if same_tag.len() > 1 {
133            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
134            format!("{tag}[{n}]")
135        } else {
136            tag.to_string()
137        }
138    }
139}
140
141/// Intern the element `el` (child of `parent`), returning its node id.
142/// Text and comment children are skipped as nodes but captured in the
143/// element's text content.
144///
145/// Iterative (an explicit work stack rather than self-recursion) so
146/// pathologically deep markup cannot overflow the call stack; html5ever
147/// parses such input iteratively, so a recursive interning pass was the
148/// only remaining depth limit. Elements are interned in document
149/// (pre-order) order, so node ids, child ordering, and the
150/// first-occurrence `ids` map are identical to the recursive form.
151fn build(
152    el: ElementRef,
153    parent: Option<NodeId>,
154    nodes: &mut Vec<Node>,
155    ids: &mut HashMap<String, NodeId>,
156) -> NodeId {
157    let root_this = NodeId(nodes.len() as u64);
158    // Work stack of (element, parent id), popped in pre-order.
159    let mut stack = vec![(el, parent)];
160
161    while let Some((el, parent)) = stack.pop() {
162        let this = NodeId(nodes.len() as u64);
163
164        let tag = el.value().name().to_string();
165        let attrs: Vec<(String, String)> = el
166            .value()
167            .attrs()
168            .map(|(k, v)| (k.to_string(), v.to_string()))
169            .collect();
170        let text: String = el.text().collect();
171
172        if let Some(id) = el.value().id() {
173            ids.entry(id.to_string()).or_insert(this);
174        }
175
176        nodes.push(Node {
177            tag: Some(tag),
178            attrs,
179            text,
180            parent,
181            children: Vec::new(),
182        });
183
184        // Record the child under its parent as it is interned; because
185        // siblings are pushed reversed below, they pop in document order
186        // and so append in document order.
187        if let Some(p) = parent {
188            nodes[p.0 as usize].children.push(this);
189        }
190
191        // Push element children in reverse so the leftmost is popped
192        // (and interned) first — a pre-order DFS.
193        let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
194        for child in children.into_iter().rev() {
195            stack.push((child, Some(this)));
196        }
197    }
198
199    root_this
200}
201
202/// Block-level HTML element tags.
203const BLOCK: &[&str] = &[
204    "address",
205    "article",
206    "aside",
207    "blockquote",
208    "body",
209    "details",
210    "dialog",
211    "dd",
212    "div",
213    "dl",
214    "dt",
215    "fieldset",
216    "figcaption",
217    "figure",
218    "footer",
219    "form",
220    "h1",
221    "h2",
222    "h3",
223    "h4",
224    "h5",
225    "h6",
226    "header",
227    "hgroup",
228    "hr",
229    "html",
230    "li",
231    "main",
232    "nav",
233    "ol",
234    "p",
235    "pre",
236    "section",
237    "table",
238    "ul",
239];
240
241fn is_heading(tag: &str) -> bool {
242    matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
243}
244
245impl AstAdapter for HtmlAdapter {
246    fn root(&self) -> NodeId {
247        self.root
248    }
249
250    fn children(&self, node: NodeId) -> Vec<NodeId> {
251        self.nodes[node.0 as usize].children.clone()
252    }
253
254    fn name(&self, node: NodeId) -> Option<String> {
255        self.nodes[node.0 as usize].tag.clone()
256    }
257
258    fn parent(&self, node: NodeId) -> Option<NodeId> {
259        self.nodes[node.0 as usize].parent
260    }
261
262    /// Structural traits: `<block>` or `<inline>`, plus `<heading>`
263    /// for `h1`–`h6` and `<link>` for an anchor with an `href`.
264    fn traits(&self, node: NodeId) -> Vec<String> {
265        let n = &self.nodes[node.0 as usize];
266        let Some(tag) = &n.tag else {
267            return Vec::new();
268        };
269        let mut out = Vec::new();
270        if BLOCK.contains(&tag.as_str()) {
271            out.push("block".to_string());
272        } else {
273            out.push("inline".to_string());
274        }
275        if is_heading(tag) {
276            out.push("heading".to_string());
277        }
278        if tag == "a" && n.attr("href").is_some() {
279            out.push("link".to_string());
280        }
281        out
282    }
283
284    /// Attributes, by name (`::href`). Element text is the bare
285    /// projection (`::`) — no attribute name is shadowed.
286    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
287        self.nodes[node.0 as usize]
288            .attr(name)
289            .map(|v| Value::Str(v.to_string()))
290    }
291
292    /// The default projection of an element is its text content.
293    fn default_value(&self, node: NodeId) -> Option<Value> {
294        Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
295    }
296
297    /// `::::tag`, `::::classes` (the class attribute, split),
298    /// `::::attrs` (the attribute names, sorted — the parser does
299    /// not preserve source order), and `::::n-attrs` — facts about
300    /// the element, never its data: attribute values are properties
301    /// (`::href`), text is the bare projection. `attrs` is what
302    /// makes a renamed attribute discoverable: enumerate the names,
303    /// probe the values by shape.
304    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
305        let n = &self.nodes[node.0 as usize];
306        match key {
307            "tag" => n.tag.clone().map(Value::Str),
308            "classes" => n.attr("class").map(|c| {
309                Value::list(
310                    c.split_whitespace()
311                        .map(|s| Value::Str(s.to_string()))
312                        .collect(),
313                )
314            }),
315            "attrs" => {
316                let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
317                names.sort_unstable();
318                Some(Value::list(
319                    names
320                        .into_iter()
321                        .map(|k| Value::Str(k.to_string()))
322                        .collect(),
323                ))
324            }
325            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
326            _ => None,
327        }
328    }
329
330    /// Follow an attribute that is a fragment reference (`#section`) to
331    /// the element with that `id`. Used by an anchor's `::href-->`.
332    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
333        if property == "href" && !self.rel_ok(node, hint) {
334            return None;
335        }
336        let value = self.nodes[node.0 as usize].attr(property)?;
337        let fragment = value.strip_prefix('#')?;
338        self.ids.get(fragment).copied()
339    }
340
341    /// The external reference an `href`-like attribute holds: the
342    /// absolute URL, relative values joined against the document's
343    /// own URL (when the mount declared one). A bare `#fragment`
344    /// stays in-document (that is `resolve`'s job); a fragment on
345    /// a cross-document URL rides along — it is the crossref
346    /// within the target, landed by `resolve_fragment` once that
347    /// document is mounted. Non-web schemes reference nothing
348    /// followable. Feeds `::href-->` across documents — the
349    /// acquisition arrow.
350    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
351        if property == "href" && !self.rel_ok(node, hint) {
352            return None;
353        }
354        let value = self.nodes[node.0 as usize].attr(property)?;
355        let t = value.trim();
356        if t.is_empty() || t.starts_with('#') {
357            return None;
358        }
359        let u = match url::Url::parse(t) {
360            Ok(u) => u,
361            Err(url::ParseError::RelativeUrlWithoutBase) => {
362                self.document_url.as_ref()?.join(t).ok()?
363            }
364            Err(_) => return None,
365        };
366        if u.scheme() != "http" && u.scheme() != "https" {
367            return None;
368        }
369        Some(u.into())
370    }
371
372    /// html's fragment landing: the element whose `id` is
373    /// `fragment`, anywhere in this document.
374    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
375        self.ids.get(fragment).copied()
376    }
377
378    /// The bare arrow's property: an anchor-family element's
379    /// `href`, a frame's `src`.
380    fn ref_property(&self, node: NodeId) -> Option<String> {
381        let n = &self.nodes[node.0 as usize];
382        let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
383            Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
384            Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
385            _ => return None,
386        };
387        let _ = tags;
388        n.attr(prop).map(|_| prop.to_string())
389    }
390
391    /// The relation an href edge carries: the `rel` attribute —
392    /// `<link rel="stylesheet">`, `<a rel="nofollow">`. The spec's
393    /// `-->stylesheet` hint filters on it; absent, the edge label
394    /// falls back to the property name.
395    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
396        if property != "href" {
397            return None;
398        }
399        let rel = self.nodes[node.0 as usize].attr("rel")?;
400        let rel = rel.trim();
401        (!rel.is_empty()).then(|| rel.to_string())
402    }
403}