Skip to main content

rmut_core/
markdown.rs

1//! Markdown compose: the text/html half of a draft written in
2//! markdown, the text/plain half being the draft as typed.
3//!
4//! Mail is not a web page, so three things differ from rendering a
5//! README. Raw HTML in the draft is shown as the text it is, since
6//! "a <b> c" in a mail is prose, not markup. A bare URL becomes a link,
7//! the way a reader of the plain half would take it. And the
8//! signature, everything below the "-- " line, keeps its lines as
9//! typed instead of being run together into a paragraph (or read as
10//! a heading underline).
11
12use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd, html};
13
14use crate::links::link_spans;
15
16/// The whole text/html document for a markdown body.
17pub 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
34/// The body above the signature separator, and the signature below it.
35fn 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
45/// The markdown itself, CommonMark plus tables, strikethrough and task
46/// lists.
47fn render(text: &str) -> String {
48    let options =
49        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
50    let mut events = Vec::new();
51    // Inside a link or code the text stays as it is.
52    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('&', "&amp;")
86        .replace('<', "&lt;")
87        .replace('>', "&gt;")
88        .replace('"', "&quot;")
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 &lt;b&gt; 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}