OXYGENHUB
# ca0c443c30cf oxygenhub / crates / hub / src / render / markdown.rs
3.2 KiB · Rust · last changed Cache rendered Markdown and highlighted files by content, in memory-bounded LRUs 1 month ago source blame history raw
1
//! Markdown to sanitised HTML: pulldown-cmark, then ammonia with an
2
//! allow-list (hub spec §15.2). Relative links and images are rebased on
3
//! `base`, so a README's `docs/x.md` opens in the hub.
4
5
use pulldown_cmark::{html, Event, Options, Parser, Tag};
6
7
/// Rendered Markdown by (text, link bases): 32 MiB.
8
fn cache() -> &'static super::cache::Lru<String> {
9
    static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new();
10
    C.get_or_init(|| super::cache::Lru::new(32 << 20))
11
}
12
13
/// (hits, misses, bytes) of the rendered Markdown cache.
14
pub fn cache_stats() -> (u64, u64, usize) {
15
    cache().stats()
16
}
17
18
/// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to
19
/// files and for images. Cached by the text and both bases.
20
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
21
    let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]);
22
    cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone()
23
}
24
25
fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String {
26
    let mut opts = Options::empty();
27
    opts.insert(Options::ENABLE_TABLES);
28
    opts.insert(Options::ENABLE_STRIKETHROUGH);
29
    opts.insert(Options::ENABLE_TASKLISTS);
30
    opts.insert(Options::ENABLE_FOOTNOTES);
31
    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
32
    let parser = Parser::new_ext(text, opts).map(|ev| match ev {
33
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => {
34
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
35
        }
36
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
37
            Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id })
38
        }
39
        other => other,
40
    });
41
    let mut out = String::with_capacity(text.len() * 2);
42
    html::push_html(&mut out, parser);
43
    let mut builder = ammonia::Builder::default();
44
    builder.add_generic_attributes(["id", "class"]);
45
    builder.add_tag_attributes("input", ["type", "checked", "disabled"]);
46
    builder.add_tags(["input"]);
47
    builder.link_rel(Some("noopener noreferrer"));
48
    builder.clean(&out).to_string()
49
}
50
51
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
52
fn rebase(url: &str, base: &str) -> String {
53
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
54
        return url.to_string();
55
    }
56
    let url = url.strip_prefix("./").unwrap_or(url);
57
    format!("{base}{url}")
58
}
59
60
#[cfg(test)]
61
mod tests {
62
    use super::*;
63
64
    #[test]
65
    fn renders_and_sanitises() {
66
        let html = render(
67
            "# Hi\n\nSee [docs](docs/x.md) and ![i](img.png) <script>alert(1)</script>\n\n- [ ] task\n",
68
            "/a/b/file/main/",
69
            "/a/b/file/main/",
70
        );
71
        assert!(html.contains("<h1>Hi</h1>"));
72
        assert!(html.contains("href=\"/a/b/file/main/docs/x.md\""));
73
        assert!(html.contains("src=\"/a/b/file/main/img.png\""));
74
        assert!(!html.contains("<script"));
75
        assert!(html.contains("type=\"checkbox\""));
76
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
77
    }
78
}