1use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd, html};
13
14use crate::links::link_spans;
15
16pub fn to_html(body: &str) -> String {
18 let (text, signature) = split_signature(body);
19 let mut out =
20 String::from("<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"></head><body>\n");
21 out += &render(text);
22 if let Some(sig) = signature {
23 out += "<p class=\"signature\">-- ";
24 for line in sig.lines() {
25 out += "<br>\n";
26 out += &escape(line);
27 }
28 out += "</p>\n";
29 }
30 out += "</body></html>\n";
31 out
32}
33
34fn split_signature(body: &str) -> (&str, Option<&str>) {
36 if let Some(sig) = body.strip_prefix("-- \n") {
37 return ("", Some(sig));
38 }
39 match body.rfind("\n-- \n") {
40 Some(at) => (&body[..at + 1], Some(&body[at + 5..])),
41 None => (body, None),
42 }
43}
44
45fn render(text: &str) -> String {
48 let options =
49 Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
50 let mut events = Vec::new();
51 let mut plain = 0usize;
53 for event in Parser::new_ext(text, options) {
54 match event {
55 Event::Start(tag @ (Tag::Link { .. } | Tag::CodeBlock(_) | Tag::Image { .. })) => {
56 plain += 1;
57 events.push(Event::Start(tag));
58 }
59 Event::End(end @ (TagEnd::Link | TagEnd::CodeBlock | TagEnd::Image)) => {
60 plain = plain.saturating_sub(1);
61 events.push(Event::End(end));
62 }
63 Event::Html(raw) | Event::InlineHtml(raw) => events.push(Event::Text(raw)),
64 Event::Text(t) if plain == 0 && t.contains("http") => {
65 for (piece, url) in link_spans(&t) {
66 events.push(match url {
67 Some(url) => Event::InlineHtml(CowStr::from(format!(
68 "<a href=\"{}\">{}</a>",
69 escape(&url),
70 escape(&piece)
71 ))),
72 None => Event::Text(CowStr::from(piece)),
73 });
74 }
75 }
76 other => events.push(other),
77 }
78 }
79 let mut out = String::new();
80 html::push_html(&mut out, events.into_iter());
81 out
82}
83
84fn escape(text: &str) -> String {
85 text.replace('&', "&")
86 .replace('<', "<")
87 .replace('>', ">")
88 .replace('"', """)
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn markdown_renders_and_mail_stays_mail() {
97 let html = to_html(
98 "Hi *Jane*,\n\n\
99 the **plan**:\n\n\
100 - one\n- two\n\n\
101 > quoted from you\n\n\
102 see https://example.com/x. and a <b> c\n\n\
103 ```\nlet x = \"https://not.a.link\";\n```\n\
104 -- \nAlex\nACME Ltd\n",
105 );
106 assert!(html.contains("<em>Jane</em>"), "{html}");
107 assert!(html.contains("<strong>plan</strong>"), "{html}");
108 assert!(html.contains("<li>one</li>"), "{html}");
109 assert!(html.contains("<blockquote>"), "{html}");
110 assert!(
111 html.contains("<a href=\"https://example.com/x\">https://example.com/x</a>."),
112 "a bare URL is a link, the full stop outside it: {html}"
113 );
114 assert!(html.contains("a <b> c"), "raw html is text: {html}");
115 assert!(
116 html.contains("let x = \"https://not.a.link\";")
117 && !html.contains("href=\"https://not"),
118 "code stays code: {html}"
119 );
120 assert!(
121 html.contains("<p class=\"signature\">-- <br>\nAlex<br>\nACME Ltd</p>"),
122 "the signature keeps its lines: {html}"
123 );
124 assert!(!html.contains("<h2>"), "the -- line is no heading: {html}");
125 }
126
127 #[test]
128 fn a_link_written_as_markdown_is_not_linked_twice() {
129 let html = to_html("[the docs](https://example.com/docs)\n");
130 assert_eq!(html.matches("<a ").count(), 1, "{html}");
131 }
132}