webfetch/convert/
markdown.rs1use ego_tree::NodeRef;
11use scraper::node::Node;
12use scraper::Html;
13
14use super::text::RefCollector;
15use crate::extract;
16use crate::types::UrlReference;
17
18fn walk(node: NodeRef<Node>, out: &mut String, refs: &mut RefCollector) {
19 match node.value() {
20 Node::Text(t) => out.push_str(&t[..]),
21 Node::Element(el) => {
22 let name = el.name();
23 if super::is_skippable(name) {
24 return;
25 }
26
27 let prefix = match name {
28 "h1" => Some("\n# "),
29 "h2" => Some("\n## "),
30 "h3" => Some("\n### "),
31 "h4" => Some("\n#### "),
32 "h5" => Some("\n##### "),
33 "h6" => Some("\n###### "),
34 "li" => Some("\n- "),
35 "blockquote" => Some("\n> "),
36 _ => None,
37 };
38
39 if name == "br" {
40 out.push('\n');
41 return;
42 }
43
44 if name == "a" {
45 let mut inner = String::new();
46 for child in node.children() {
47 walk(child, &mut inner, refs);
48 }
49 let inner = inner.trim().to_string();
50 match el.attr("href").and_then(|href| refs.resolve(href)) {
51 Some(url) => {
52 refs.index_for(url.clone(), &inner);
53 out.push_str(&format!("[{inner}]({url})"));
54 }
55 None => out.push_str(&inner),
56 }
57 return;
58 }
59
60 if name == "code" {
61 let mut inner = String::new();
62 for child in node.children() {
63 walk(child, &mut inner, refs);
64 }
65 out.push_str(&format!("`{}`", inner.trim()));
66 return;
67 }
68
69 if let Some(p) = prefix {
70 out.push_str(p);
71 }
72 for child in node.children() {
73 walk(child, out, refs);
74 }
75 if matches!(
76 name,
77 "p" | "div" | "section" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
78 ) {
79 out.push('\n');
80 }
81 }
82 _ => {}
83 }
84}
85
86pub fn markdown_with_refs(doc: &Html, base_url: &str) -> (String, Vec<UrlReference>) {
88 let root = match extract::content_root(doc) {
89 Some(el) => el,
90 None => return (String::new(), Vec::new()),
91 };
92 let mut refs = RefCollector::new(base_url);
93 let mut out = String::new();
94 for child in root.children() {
95 walk(child, &mut out, &mut refs);
96 }
97 (out, refs.references)
98}
99
100pub fn html_to_markdown(html: &str, base_url: &str) -> String {
102 markdown_with_refs(&Html::parse_document(html), base_url).0
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn unfetchable_schemes_stay_plain_text() {
111 let html = r#"<article><p><a href="javascript:alert(1)">click</a>
112 and <a href="/ok">ok</a></p></article>"#;
113 let (md, refs) = markdown_with_refs(&Html::parse_document(html), "https://x.test/");
114 assert!(!md.contains("javascript:"), "md: {md}");
115 assert!(!md.contains("[click]("), "js link must not stay live: {md}");
116 assert!(md.contains("click"), "anchor text is still kept: {md}");
117 assert!(md.contains("[ok](https://x.test/ok)"), "md: {md}");
118 assert_eq!(refs.len(), 1);
119 }
120}