| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | | use pulldown_cmark::{html, Event, Options, Parser, Tag}; |
| 6 | |
|
| 7 | |
|
| 8 | 253× | fn cache() -> &'static super::cache::Lru<String> { |
| 9 | | static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new(); |
| 10 | 253× | C.get_or_init(|| super::cache::Lru::new(32 << 20)) |
| 11 | 253× | } |
| 12 | |
|
| 13 | |
|
| 14 | 0× | pub fn cache_stats() -> (u64, u64, usize) { |
| 15 | 0× | cache().stats() |
| 16 | 0× | } |
| 17 | |
|
| 18 | |
|
| 19 | |
|
| 20 | 253× | pub fn render(text: &str, base_file: &str, base_raw: &str) -> String { |
| 21 | 253× | let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]); |
| 22 | 253× | cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone() |
| 23 | 253× | } |
| 24 | |
|
| 25 | 59× | fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String { |
| 26 | 59× | let mut opts = Options::empty(); |
| 27 | 59× | opts.insert(Options::ENABLE_TABLES); |
| 28 | 59× | opts.insert(Options::ENABLE_STRIKETHROUGH); |
| 29 | 59× | opts.insert(Options::ENABLE_TASKLISTS); |
| 30 | 59× | opts.insert(Options::ENABLE_FOOTNOTES); |
| 31 | 59× | opts.insert(Options::ENABLE_HEADING_ATTRIBUTES); |
| 32 | 91× | let parser = Parser::new_ext(text, opts).map(|ev| match ev { |
| 33 | 16× | Event::Start(Tag::Link { link_type, dest_url, title, id }) => { |
| 34 | 16× | Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id }) |
| 35 | | } |
| 36 | 4× | Event::Start(Tag::Image { link_type, dest_url, title, id }) => { |
| 37 | 4× | Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id }) |
| 38 | | } |
| 39 | 274× | other => other, |
| 40 | 294× | }); |
| 41 | 59× | let mut out = String::with_capacity(text.len() * 2); |
| 42 | 59× | html::push_html(&mut out, parser); |
| 43 | 59× | let mut builder = ammonia::Builder::default(); |
| 44 | 59× | builder.add_generic_attributes(["id", "class"]); |
| 45 | 59× | builder.add_tag_attributes("input", ["type", "checked", "disabled"]); |
| 46 | 59× | builder.add_tags(["input"]); |
| 47 | 59× | builder.link_rel(Some("noopener noreferrer")); |
| 48 | 59× | builder.clean(&out).to_string() |
| 49 | 59× | } |
| 50 | |
|
| 51 | |
|
| 52 | 20× | fn rebase(url: &str, base: &str) -> String { |
| 53 | 20× | if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") { |
| 54 | 8× | return url.to_string(); |
| 55 | 12× | } |
| 56 | 12× | let url = url.strip_prefix("./").unwrap_or(url); |
| 57 | 12× | format!("{base}{url}") |
| 58 | 20× | } |
| 59 | |
|
| 60 | | #[cfg(test)] |
| 61 | | mod tests { |
| 62 | | use super::*; |
| 63 | |
|
| 64 | | #[test] |
| 65 | 1× | fn renders_and_sanitises() { |
| 66 | 1× | let html = render( |
| 67 | 1× | "# Hi\n\nSee [docs](docs/x.md) and  <script>alert(1)</script>\n\n- [ ] task\n", |
| 68 | 1× | "/a/b/file/main/", |
| 69 | 1× | "/a/b/file/main/", |
| 70 | | ); |
| 71 | 1× | assert!(html.contains("<h1>Hi</h1>")); |
| 72 | 1× | assert!(html.contains("href=\"/a/b/file/main/docs/x.md\"")); |
| 73 | 1× | assert!(html.contains("src=\"/a/b/file/main/img.png\"")); |
| 74 | 1× | assert!(!html.contains("<script")); |
| 75 | 1× | assert!(html.contains("type=\"checkbox\"")); |
| 76 | 1× | assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\"")); |
| 77 | 1× | } |
| 78 | | } |