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