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/// The encoding an HTML document declares in its head — a `<meta
46/// charset=…>` or a `<meta http-equiv="content-type"
47/// content="…; charset=…">` within the first 1024 bytes, as the
48/// HTML standard's prescan reads them — reported for the engine's
49/// source boundary. The adapter decodes nothing. An XML prologue
50/// on an XHTML page counts too (the XML adapter's rule, applied
51/// here so a page served as text/html still declares). A
52/// `charset=` inside any other attribute's value (a description)
53/// declares nothing.
54pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
55    if let Some(label) = xml_prologue_encoding(bytes) {
56        return Some(label);
57    }
58    let head = &bytes[..bytes.len().min(1024)];
59    let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
60    let mut at = 0;
61    while let Some(i) = find(&lower[at..], b"<meta") {
62        let start = at + i + 5;
63        let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
64        let attrs = meta_attributes(&lower[start..end]);
65        let get = |name: &str| {
66            attrs
67                .iter()
68                .find(|(k, _)| k == name)
69                .map(|(_, v)| v.as_str())
70        };
71        if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
72            return Some(label.to_string());
73        }
74        if get("http-equiv") == Some("content-type")
75            && let Some(content) = get("content")
76            && let Some(p) = content.find("charset=")
77        {
78            let label = content[p + 8..]
79                .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
80                .split([';', ' ', '"', '\''])
81                .next()
82                .unwrap_or("");
83            if !label.is_empty() {
84                return Some(label.to_string());
85            }
86        }
87        at = end;
88    }
89    None
90}
91
92/// `<?xml … encoding="…"?>` at the very start — the one XML rule
93/// the HTML reporter shares, kept local so the crates stay apart.
94/// The target must be `xml` itself (`<?xml-stylesheet …?>` is
95/// another instruction and declares nothing).
96fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
97    let head = bytes.strip_prefix(b"<?xml")?;
98    if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
99        return None;
100    }
101    let end = find(head, b"?>")?;
102    let prologue = std::str::from_utf8(&head[..end]).ok()?;
103    let rest = prologue
104        .split_once("encoding")?
105        .1
106        .trim_start()
107        .strip_prefix('=')?
108        .trim_start();
109    let quote = rest.chars().next()?;
110    (quote == '"' || quote == '\'')
111        .then(|| {
112            rest[1..]
113                .split(quote)
114                .next()
115                .unwrap_or("")
116                .trim()
117                .to_string()
118        })
119        .filter(|l| !l.is_empty())
120}
121
122fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
123    hay.windows(needle.len()).position(|w| w == needle)
124}
125
126/// The attributes of a lowercased tag body, as (name, value): a
127/// value quoted or bare, a name without one given the empty value.
128/// Quoted values are skipped as wholes, so nothing inside one is
129/// taken for an attribute.
130fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
131    let mut out = Vec::new();
132    let mut i = 0;
133    let n = tag.len();
134    while i < n {
135        while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
136            i += 1;
137        }
138        let name_start = i;
139        while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
140            i += 1;
141        }
142        if i == name_start {
143            break;
144        }
145        let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
146        while i < n && tag[i].is_ascii_whitespace() {
147            i += 1;
148        }
149        let mut value = String::new();
150        if i < n && tag[i] == b'=' {
151            i += 1;
152            while i < n && tag[i].is_ascii_whitespace() {
153                i += 1;
154            }
155            if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
156                let q = tag[i];
157                i += 1;
158                let v = i;
159                while i < n && tag[i] != q {
160                    i += 1;
161                }
162                value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
163                i = (i + 1).min(n);
164            } else {
165                let v = i;
166                while i < n && !tag[i].is_ascii_whitespace() {
167                    i += 1;
168                }
169                value = String::from_utf8_lossy(&tag[v..i]).to_string();
170            }
171        }
172        out.push((name, value));
173    }
174    out
175}
176
177/// A Quarb adapter over a parsed HTML document.
178pub struct HtmlAdapter {
179    nodes: Vec<Node>,
180    /// `id` attribute value → the element carrying it.
181    ids: HashMap<String, NodeId>,
182    root: NodeId,
183    /// The document's own URL, when the mount knows it — the base
184    /// a relative `href` joins against for `::href-->` across
185    /// documents. `None` for pasted or fileless html: only
186    /// absolute hrefs reference out then.
187    document_url: Option<url::Url>,
188}
189
190impl HtmlAdapter {
191    /// Does `node`'s `rel` admit the relation `hint`? No hint and
192    /// the `*` wildcard admit everything; a named hint needs the
193    /// space-separated `rel` list to contain it (`rel="nofollow
194    /// noopener"` answers to either word). An element without a
195    /// `rel` answers only the wildcard forms — the spec's
196    /// `-->stylesheet` reads "only the stylesheet relation".
197    fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
198        match hint {
199            None | Some("*") => true,
200            Some(h) => self.nodes[node.0 as usize]
201                .attr("rel")
202                .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
203        }
204    }
205
206    /// Declare the document's own URL — the base a relative `href`
207    /// joins against when `::href-->` reaches across documents.
208    pub fn set_document_url(&mut self, url: &str) {
209        self.document_url = url::Url::parse(url).ok();
210    }
211
212    /// Parse `html` and build the adapter.
213    pub fn parse(html: &str) -> Self {
214        let document = Html::parse_document(html);
215        let mut nodes = vec![Node {
216            tag: None,
217            attrs: Vec::new(),
218            text: String::new(),
219            parent: None,
220            children: Vec::new(),
221        }];
222        let mut ids = HashMap::new();
223        let root = NodeId(0);
224
225        let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
226        nodes[0].text = nodes[html_node.0 as usize].text.clone();
227        nodes[0].children = vec![html_node];
228
229        HtmlAdapter {
230            nodes,
231            ids,
232            root,
233            document_url: None,
234        }
235    }
236
237    /// A locator path to `node`, like `/html/body/div[2]/p`, for
238    /// rendering. A `[n]` index is added only to disambiguate same-tag
239    /// siblings.
240    pub fn locator(&self, node: NodeId) -> String {
241        let mut segments = Vec::new();
242        let mut cur = Some(node);
243        while let Some(id) = cur {
244            let n = &self.nodes[id.0 as usize];
245            if let Some(tag) = &n.tag {
246                segments.push(self.segment(id, tag));
247            }
248            cur = n.parent;
249        }
250        segments.reverse();
251        format!("/{}", segments.join("/"))
252    }
253
254    fn segment(&self, node: NodeId, tag: &str) -> String {
255        let Some(parent) = self.nodes[node.0 as usize].parent else {
256            return tag.to_string();
257        };
258        let siblings = &self.nodes[parent.0 as usize].children;
259        let same_tag: Vec<NodeId> = siblings
260            .iter()
261            .copied()
262            .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
263            .collect();
264        if same_tag.len() > 1 {
265            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
266            format!("{tag}[{n}]")
267        } else {
268            tag.to_string()
269        }
270    }
271}
272
273/// Intern the element `el` (child of `parent`), returning its node id.
274/// Text and comment children are skipped as nodes but captured in the
275/// element's text content.
276///
277/// Iterative (an explicit work stack rather than self-recursion) so
278/// pathologically deep markup cannot overflow the call stack; html5ever
279/// parses such input iteratively, so a recursive interning pass was the
280/// only remaining depth limit. Elements are interned in document
281/// (pre-order) order, so node ids, child ordering, and the
282/// first-occurrence `ids` map are identical to the recursive form.
283fn build(
284    el: ElementRef,
285    parent: Option<NodeId>,
286    nodes: &mut Vec<Node>,
287    ids: &mut HashMap<String, NodeId>,
288) -> NodeId {
289    let root_this = NodeId(nodes.len() as u64);
290    // Work stack of (element, parent id), popped in pre-order.
291    let mut stack = vec![(el, parent)];
292
293    while let Some((el, parent)) = stack.pop() {
294        let this = NodeId(nodes.len() as u64);
295
296        let tag = el.value().name().to_string();
297        let attrs: Vec<(String, String)> = el
298            .value()
299            .attrs()
300            .map(|(k, v)| (k.to_string(), v.to_string()))
301            .collect();
302        let text: String = el.text().collect();
303
304        if let Some(id) = el.value().id() {
305            ids.entry(id.to_string()).or_insert(this);
306        }
307
308        nodes.push(Node {
309            tag: Some(tag),
310            attrs,
311            text,
312            parent,
313            children: Vec::new(),
314        });
315
316        // Record the child under its parent as it is interned; because
317        // siblings are pushed reversed below, they pop in document order
318        // and so append in document order.
319        if let Some(p) = parent {
320            nodes[p.0 as usize].children.push(this);
321        }
322
323        // Push element children in reverse so the leftmost is popped
324        // (and interned) first — a pre-order DFS.
325        let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
326        for child in children.into_iter().rev() {
327            stack.push((child, Some(this)));
328        }
329    }
330
331    root_this
332}
333
334/// Block-level HTML element tags.
335const BLOCK: &[&str] = &[
336    "address",
337    "article",
338    "aside",
339    "blockquote",
340    "body",
341    "details",
342    "dialog",
343    "dd",
344    "div",
345    "dl",
346    "dt",
347    "fieldset",
348    "figcaption",
349    "figure",
350    "footer",
351    "form",
352    "h1",
353    "h2",
354    "h3",
355    "h4",
356    "h5",
357    "h6",
358    "header",
359    "hgroup",
360    "hr",
361    "html",
362    "li",
363    "main",
364    "nav",
365    "ol",
366    "p",
367    "pre",
368    "section",
369    "table",
370    "ul",
371];
372
373fn is_heading(tag: &str) -> bool {
374    matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
375}
376
377impl AstAdapter for HtmlAdapter {
378    fn root(&self) -> NodeId {
379        self.root
380    }
381
382    /// In memory: an unscoped reverse resolution or value match may
383    /// walk the arbor.
384    fn walks_for_references(&self) -> bool {
385        true
386    }
387
388    fn children(&self, node: NodeId) -> Vec<NodeId> {
389        self.nodes[node.0 as usize].children.clone()
390    }
391
392    fn name(&self, node: NodeId) -> Option<String> {
393        self.nodes[node.0 as usize].tag.clone()
394    }
395
396    fn parent(&self, node: NodeId) -> Option<NodeId> {
397        self.nodes[node.0 as usize].parent
398    }
399
400    /// Structural traits: `<block>` or `<inline>`, plus `<heading>`
401    /// for `h1`–`h6` and `<link>` for an anchor with an `href`.
402    fn traits(&self, node: NodeId) -> Vec<String> {
403        let n = &self.nodes[node.0 as usize];
404        let Some(tag) = &n.tag else {
405            return Vec::new();
406        };
407        let mut out = Vec::new();
408        if BLOCK.contains(&tag.as_str()) {
409            out.push("block".to_string());
410        } else {
411            out.push("inline".to_string());
412        }
413        if is_heading(tag) {
414            out.push("heading".to_string());
415        }
416        if tag == "a" && n.attr("href").is_some() {
417            out.push("link".to_string());
418        }
419        out
420    }
421
422    /// Attributes, by name (`::href`). Element text is the bare
423    /// projection (`::`) — no attribute name is shadowed.
424    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
425        self.nodes[node.0 as usize]
426            .attr(name)
427            .map(|v| Value::Str(v.to_string()))
428    }
429
430    /// The default projection of an element is its text content.
431    fn default_value(&self, node: NodeId) -> Option<Value> {
432        Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
433    }
434
435    /// `::::tag`, `::::classes` (the class attribute, split),
436    /// `::::attrs` (the attribute names, sorted — the parser does
437    /// not preserve source order), and `::::n-attrs` — facts about
438    /// the element, never its data: attribute values are properties
439    /// (`::href`), text is the bare projection. `attrs` is what
440    /// makes a renamed attribute discoverable: enumerate the names,
441    /// probe the values by shape.
442    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
443        let n = &self.nodes[node.0 as usize];
444        match key {
445            "tag" => n.tag.clone().map(Value::Str),
446            "classes" => n.attr("class").map(|c| {
447                Value::list(
448                    c.split_whitespace()
449                        .map(|s| Value::Str(s.to_string()))
450                        .collect(),
451                )
452            }),
453            "attrs" => {
454                let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
455                names.sort_unstable();
456                Some(Value::list(
457                    names
458                        .into_iter()
459                        .map(|k| Value::Str(k.to_string()))
460                        .collect(),
461                ))
462            }
463            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
464            _ => None,
465        }
466    }
467
468    /// Follow an attribute that is a fragment reference (`#section`) to
469    /// the element with that `id`. Used by an anchor's `::href-->`.
470    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
471        if property == "href" && !self.rel_ok(node, hint) {
472            return None;
473        }
474        let value = self.nodes[node.0 as usize].attr(property)?;
475        let fragment = value.strip_prefix('#')?;
476        self.ids.get(fragment).copied()
477    }
478
479    /// The external reference an `href`-like attribute holds: the
480    /// absolute URL, relative values joined against the document's
481    /// own URL (when the mount declared one). A bare `#fragment`
482    /// stays in-document (that is `resolve`'s job); a fragment on
483    /// a cross-document URL rides along — it is the crossref
484    /// within the target, landed by `resolve_fragment` once that
485    /// document is mounted. Non-web schemes reference nothing
486    /// followable. Feeds `::href-->` across documents — the
487    /// acquisition arrow.
488    fn external_ref(
489        &self,
490        node: NodeId,
491        property: &str,
492        hint: Option<&str>,
493    ) -> Option<quarb::ExternalRef> {
494        if property == "href" && !self.rel_ok(node, hint) {
495            return None;
496        }
497        let value = self.nodes[node.0 as usize].attr(property)?;
498        let t = value.trim();
499        if t.is_empty() || t.starts_with('#') {
500            return None;
501        }
502        let u = match url::Url::parse(t) {
503            Ok(u) => u,
504            // A relative reference: joined against the document's
505            // URL where one is known; else handed over as written,
506            // for the engine to make absolute against the tree the
507            // page stands in (a file beside it in a directory).
508            Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
509                Some(base) => base.join(t).ok()?,
510                None => return Some(quarb::ExternalRef::uri(t)),
511            },
512            Err(_) => return None,
513        };
514        if u.scheme() != "http" && u.scheme() != "https" {
515            return None;
516        }
517        Some(quarb::ExternalRef::uri(u.as_str()))
518    }
519
520    /// html's fragment landing: the element whose `id` is
521    /// `fragment`, anywhere in this document.
522    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
523        self.ids.get(fragment).copied()
524    }
525
526    /// The bare arrow's property: an anchor-family element's
527    /// `href`, a frame's `src`.
528    /// An element's properties are its attributes.
529    fn property_names(&self, node: NodeId) -> Vec<String> {
530        self.nodes[node.0 as usize]
531            .attrs
532            .iter()
533            .map(|(k, _)| k.clone())
534            .collect()
535    }
536
537    /// Any attribute may hold a reference (`#id`, an IDREF) to any
538    /// element of the document: the format declares no narrower one.
539    fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
540        Some(true)
541    }
542
543    fn ref_property(&self, node: NodeId) -> Option<String> {
544        let n = &self.nodes[node.0 as usize];
545        let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
546            Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
547            Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
548            _ => return None,
549        };
550        let _ = tags;
551        n.attr(prop).map(|_| prop.to_string())
552    }
553
554    /// The relation an href edge carries: the `rel` attribute —
555    /// `<link rel="stylesheet">`, `<a rel="nofollow">`. The spec's
556    /// `-->stylesheet` hint filters on it; absent, the edge label
557    /// falls back to the property name.
558    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
559        if property != "href" {
560            return None;
561        }
562        let rel = self.nodes[node.0 as usize].attr("rel")?;
563        let rel = rel.trim();
564        (!rel.is_empty()).then(|| rel.to_string())
565    }
566}
567
568#[cfg(test)]
569mod declared_tests {
570    use super::declared_encoding;
571
572    #[test]
573    fn meta_charset_forms() {
574        assert_eq!(
575            declared_encoding(
576                b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
577            ),
578            Some("windows-1251".into())
579        );
580        assert_eq!(
581            declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
582            Some("koi8-r".into())
583        );
584        assert_eq!(
585            declared_encoding(b"<html><head><meta charset=utf-8></head>"),
586            Some("utf-8".into())
587        );
588        assert_eq!(
589            declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
590            None
591        );
592        assert_eq!(
593            declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
594            Some("ISO-8859-5".into())
595        );
596        // a charset= inside another attribute's value declares nothing
597        assert_eq!(
598            declared_encoding(
599                b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
600            ),
601            None
602        );
603        // another processing instruction is no prologue
604        assert_eq!(
605            declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
606            None
607        );
608        let mut late = vec![b' '; 1100];
609        late.extend_from_slice(b"<meta charset=\"koi8-r\">");
610        assert_eq!(declared_encoding(&late), None);
611    }
612}