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}