Skip to main content

domtree/extract/
links.rs

1//! Hyperlinks ([`Dom::links`]) and embedded resources ([`Dom::resources`]),
2//! with URLs resolved against the page's base URL.
3
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use super::{collapse, document_order, non_empty};
8use crate::node::{NodeId, NodeKind};
9use crate::serialize::{is_space, walk};
10use crate::tags::{self, Display};
11use crate::tree::{Dom, NodeRef};
12use crate::url::{is_javascript, normalize, resolve_url, srcset_candidates};
13
14/// A hyperlink — an `<a href>` or `<area href>` — found by [`Dom::links`].
15#[non_exhaustive]
16#[derive(Clone, Debug)]
17pub struct Link<'a> {
18    /// The `<a>` or `<area>` element.
19    pub node: NodeRef<'a>,
20    /// The `href` attribute exactly as written.
21    pub href: &'a str,
22    /// `href` resolved against the base URL (see [`Dom::base_url`]) with
23    /// [`resolve_url`]. Without an absolute base this is `href` cleaned up the
24    /// same way (surrounding whitespace removed), and may be relative.
25    pub url: String,
26    /// The readable link text, on one line: the element's visible text with
27    /// whitespace collapsed. When that is empty (an icon link), the first of
28    /// `aria-label`, `alt` (for `<area>`), the `alt` of a visible image
29    /// inside, or `title`; otherwise empty.
30    ///
31    /// Visible means not inside a hidden element ([`Dom::to_text`] skips the
32    /// same: `<script>`, `<template>`, …), nor inside an element of the link
33    /// that is hidden by its `hidden` attribute or by an inline `style`
34    /// declaring `display: none` or `visibility: hidden` (a simple check of
35    /// the attribute: no CSS cascade). `aria-hidden` content is kept, since it
36    /// is still shown on screen.
37    pub text: String,
38}
39
40/// A URL-valued attribute that loads something into the page — an image,
41/// script, stylesheet, media file or frame — found by [`Dom::resources`].
42#[non_exhaustive]
43#[derive(Clone, Debug)]
44pub struct Resource<'a> {
45    /// The element carrying the URL.
46    pub node: NodeRef<'a>,
47    /// The attribute it came from: `"href"` (on `<link>`), `"src"`,
48    /// `"srcset"`, `"poster"` or `"data"` (on `<object>`).
49    pub attr: &'static str,
50    /// The URL as written. For `srcset`, one candidate's URL (without its
51    /// descriptor); a `srcset` yields one `Resource` per candidate.
52    pub value: &'a str,
53    /// `value` resolved against the base URL, as for [`Link::url`].
54    pub url: String,
55}
56
57impl Dom {
58    /// The URL relative links in this document resolve against.
59    ///
60    /// That is the `href` of the first `<base href>` element, resolved against
61    /// `base` (typically the URL the page was fetched from); without a
62    /// `<base>` element, `base` itself. `None` when neither is available.
63    ///
64    /// As in a browser, a `<base>` inside a `<template>` does not count (a
65    /// template's content is not part of the document), and neither does one
66    /// inside `<svg>` or `<math>` (it is not an HTML `<base>` there).
67    ///
68    /// ```
69    /// let dom = domtree::parse(r#"<head><base href="/docs/"></head>"#);
70    /// assert_eq!(
71    ///     dom.base_url(Some("https://example.com/index.html")).as_deref(),
72    ///     Some("https://example.com/docs/"),
73    /// );
74    /// assert_eq!(dom.base_url(None).as_deref(), Some("/docs/"));
75    /// assert_eq!(domtree::parse("<p>x</p>").base_url(None), None);
76    /// ```
77    pub fn base_url(&self, base: Option<&str>) -> Option<String> {
78        let mut tag = None;
79        for root in self.roots() {
80            // Once found, nothing more is entered: the walk only steps over
81            // the remaining siblings on the way out.
82            walk(root, |node, enter| match node.tag_name() {
83                _ if !enter || tag.is_some() => false,
84                Some("base") => {
85                    tag = node.attr("href");
86                    false
87                }
88                Some("template" | "svg" | "math") => false,
89                Some(_) => true,
90                None => false,
91            });
92            if tag.is_some() {
93                break;
94            }
95        }
96        match (tag, base) {
97            (Some(href), Some(base)) => Some(resolve_url(base, href)),
98            (Some(href), None) => Some(normalize(href)).filter(|h| !h.is_empty()),
99            (None, Some(base)) => Some(normalize(base)),
100            (None, None) => None,
101        }
102    }
103
104    /// Every hyperlink in the document — each `<a href>` and `<area href>` —
105    /// in document order, with its URL resolved and its readable text.
106    ///
107    /// URLs resolve against [`Dom::base_url`]`(base)`, so a `<base href>` in
108    /// the page is honoured. `javascript:` links are skipped: they run code
109    /// rather than navigate. Fragment-only (`#top`), `mailto:` and other links
110    /// are kept; filter on [`Link::url`] as needed. Embedded resources (`<img
111    /// src>`, `<link href>`, `<script src>`, …) are not links; see
112    /// [`Dom::resources`].
113    ///
114    /// Link text comes from one pass over the document; in invalid nested
115    /// links (`<a><a>…</a></a>`), the inner text belongs to the inner link
116    /// only.
117    ///
118    /// ```
119    /// let dom = domtree::parse(
120    ///     r#"<nav><a href="/docs/">Docs</a> <a href="javascript:void(0)">Menu</a></nav>
121    ///        <p>See <a href="../faq.html#top">the
122    ///        FAQ</a> or <a href="https://example.org"><img src=logo.png alt="Example"></a>.</p>"#,
123    /// );
124    /// let links = dom.links(Some("https://example.com/en/guide/"));
125    /// let found: Vec<_> = links.iter().map(|l| (l.url.as_str(), l.text.as_str())).collect();
126    /// assert_eq!(found, [
127    ///     ("https://example.com/docs/", "Docs"),
128    ///     ("https://example.com/en/faq.html#top", "the FAQ"),
129    ///     ("https://example.org", "Example"),
130    /// ]);
131    /// assert_eq!(links[1].href, "../faq.html#top");
132    /// ```
133    pub fn links(&self, base: Option<&str>) -> Vec<Link<'_>> {
134        let base = self.base_url(base);
135        let base = base.as_deref().unwrap_or("");
136        let mut links: Vec<Link<'_>> = Vec::new();
137        let mut collector = TextCollector::default();
138        for root in self.roots() {
139            walk(root, |node, enter| {
140                collector.visit(node, enter, |node, href| {
141                    links.push(Link {
142                        node,
143                        href,
144                        url: resolve_url(base, href),
145                        text: String::new(),
146                    });
147                    links.len() - 1
148                })
149            });
150        }
151        for (i, state) in collector.done.into_iter().enumerate() {
152            if let Some(link) = links.get_mut(i) {
153                link.text = state.finish(link.node);
154            }
155        }
156        links
157    }
158
159    /// Every embedded resource URL in the document, in document order:
160    /// `<link href>` (stylesheets, icons, feeds, …), every `src` (images,
161    /// scripts, frames, media, …), each candidate of a `srcset`, `poster` and
162    /// `<object data>`, resolved against [`Dom::base_url`]`(base)`.
163    ///
164    /// Empty values and `javascript:` URLs are skipped. For each element the
165    /// attributes come in the order `href`, `src`, `srcset`, `poster`, `data`;
166    /// if an attribute is repeated, the first one counts (as with
167    /// [`NodeRef::attr`]).
168    ///
169    /// ```
170    /// let dom = domtree::parse(
171    ///     r#"<link rel=stylesheet href=/main.css>
172    ///        <img src="a.png" srcset="a.png 1x, a@2x.png 2x">
173    ///        <script src="https://cdn.example.com/x.js"></script>"#,
174    /// );
175    /// let urls: Vec<_> = dom
176    ///     .resources(Some("https://example.com/blog/"))
177    ///     .into_iter()
178    ///     .map(|r| (r.attr, r.url))
179    ///     .collect();
180    /// assert_eq!(urls, [
181    ///     ("href", "https://example.com/main.css".to_string()),
182    ///     ("src", "https://example.com/blog/a.png".to_string()),
183    ///     ("srcset", "https://example.com/blog/a.png".to_string()),
184    ///     ("srcset", "https://example.com/blog/a@2x.png".to_string()),
185    ///     ("src", "https://cdn.example.com/x.js".to_string()),
186    /// ]);
187    /// ```
188    pub fn resources(&self, base: Option<&str>) -> Vec<Resource<'_>> {
189        let base = self.base_url(base);
190        let base = base.as_deref().unwrap_or("");
191        let mut out = Vec::new();
192        for node in document_order(self) {
193            let Some(tag) = node.tag_name() else {
194                continue;
195            };
196            let mut attrs: [Option<&'static str>; 5] =
197                [None, Some("src"), Some("srcset"), Some("poster"), None];
198            if tag == "link" {
199                attrs[0] = Some("href");
200            }
201            if tag == "object" {
202                attrs[4] = Some("data");
203            }
204            for attr in attrs.into_iter().flatten() {
205                let Some(value) = node.attr(attr) else {
206                    continue;
207                };
208                if attr == "srcset" {
209                    for (url, _) in srcset_candidates(value) {
210                        add_resource(&mut out, base, node, attr, url);
211                    }
212                } else {
213                    add_resource(&mut out, base, node, attr, value);
214                }
215            }
216        }
217        out
218    }
219}
220
221fn add_resource<'a>(
222    out: &mut Vec<Resource<'a>>,
223    base: &str,
224    node: NodeRef<'a>,
225    attr: &'static str,
226    value: &'a str,
227) {
228    if non_empty(value).is_some() && !is_javascript(value) {
229        out.push(Resource {
230            node,
231            attr,
232            value,
233            url: resolve_url(base, value),
234        });
235    }
236}
237
238/// Collects the readable text of every link in one walk (so nested links cost
239/// no extra passes): text goes to the innermost open link.
240#[derive(Default)]
241struct TextCollector<'a> {
242    /// Open links: (element, index into `done`).
243    open: Vec<(NodeRef<'a>, usize)>,
244    done: Vec<LinkText<'a>>,
245    /// The hidden element inside a link that the walk is in, if any: nothing
246    /// in it counts toward any link's text (links in it are still found).
247    hidden: Option<NodeId>,
248}
249
250#[derive(Default)]
251struct LinkText<'a> {
252    text: String,
253    space: bool,
254    /// The `alt` of the first image inside, as a fallback.
255    alt: Option<&'a str>,
256}
257
258impl<'a> TextCollector<'a> {
259    /// Visit a node during `walk`; `found` registers a new link and returns its
260    /// index. Returns whether to descend.
261    fn visit(
262        &mut self,
263        node: NodeRef<'a>,
264        enter: bool,
265        mut found: impl FnMut(NodeRef<'a>, &'a str) -> usize,
266    ) -> bool {
267        let tag = match node.kind() {
268            NodeKind::Element { tag } => tag,
269            NodeKind::Text(t) => {
270                if self.hidden.is_none() {
271                    if let Some(link) = self.current() {
272                        link.push(t);
273                    }
274                }
275                return false;
276            }
277            NodeKind::Comment(_) | NodeKind::Doctype(_) => return false,
278        };
279        if !enter {
280            if self.open.last().is_some_and(|(n, _)| n.id() == node.id()) {
281                self.open.pop();
282            }
283            if self.hidden.is_some() {
284                if self.hidden == Some(node.id()) {
285                    self.hidden = None;
286                }
287                return false;
288            }
289            if tags::display(tag) != Display::Inline {
290                if let Some(link) = self.current() {
291                    link.space = true;
292                }
293            }
294            return false;
295        }
296        let display = tags::display(tag);
297        if display == Display::Hidden {
298            return false;
299        }
300        if self.hidden.is_none() && !self.open.is_empty() && is_hidden(node) {
301            self.hidden = Some(node.id());
302        }
303        if self.hidden.is_none() {
304            if let Some(link) = self.current() {
305                if display != Display::Inline || tag == "br" {
306                    link.space = true;
307                }
308                if tag == "img" && link.alt.is_none() {
309                    link.alt = node.attr("alt").and_then(non_empty);
310                }
311            }
312        }
313        if matches!(tag, "a" | "area") {
314            if let Some(href) = node.attr("href").filter(|h| !is_javascript(h)) {
315                let index = found(node, href);
316                // `found` pushes links in order, so `index == done.len()`.
317                self.done.push(LinkText::default());
318                self.open.push((node, index));
319            }
320        }
321        true
322    }
323
324    fn current(&mut self) -> Option<&mut LinkText<'a>> {
325        let &(_, index) = self.open.last()?;
326        self.done.get_mut(index)
327    }
328}
329
330/// Whether `node` hides itself: a `hidden` attribute, or an inline `style`
331/// with a `display: none` or `visibility: hidden` declaration (names and
332/// values ASCII case-insensitive, `!important` allowed; no CSS cascade).
333fn is_hidden(node: NodeRef<'_>) -> bool {
334    node.has_attr("hidden")
335        || node.attr("style").is_some_and(|style| {
336            style.split(';').any(|declaration| {
337                let Some((name, value)) = declaration.split_once(':') else {
338                    return false;
339                };
340                let name = name.trim_matches(|c: char| c.is_ascii_whitespace());
341                let value = value
342                    .split(|c: char| c.is_ascii_whitespace() || c == '!')
343                    .find(|v| !v.is_empty())
344                    .unwrap_or("");
345                (name.eq_ignore_ascii_case("display") && value.eq_ignore_ascii_case("none"))
346                    || (name.eq_ignore_ascii_case("visibility")
347                        && value.eq_ignore_ascii_case("hidden"))
348            })
349        })
350}
351
352impl<'a> LinkText<'a> {
353    fn push(&mut self, text: &str) {
354        for (i, word) in text.split(is_space).enumerate() {
355            if i > 0 {
356                self.space = true;
357            }
358            if !word.is_empty() {
359                if self.space && !self.text.is_empty() {
360                    self.text.push(' ');
361                }
362                self.space = false;
363                self.text.push_str(word);
364            }
365        }
366    }
367
368    fn finish(self, node: NodeRef<'a>) -> String {
369        if !self.text.is_empty() {
370            return self.text;
371        }
372        let attr = |name| node.attr(name).and_then(non_empty);
373        attr("aria-label")
374            .or_else(|| attr("alt"))
375            .or(self.alt)
376            .or_else(|| attr("title"))
377            .map(collapse)
378            .unwrap_or_default()
379    }
380}