//! Markdown to sanitised HTML: pulldown-cmark, then ammonia with an //! allow-list (hub spec ยง15.2). Relative links and images are rebased on //! `base`, so a README's `docs/x.md` opens in the hub. use pulldown_cmark::{html, Event, Options, Parser, Tag}; /// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to /// files and for images. pub fn render(text: &str, base_file: &str, base_raw: &str) -> String { let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_FOOTNOTES); opts.insert(Options::ENABLE_HEADING_ATTRIBUTES); let parser = Parser::new_ext(text, opts).map(|ev| match ev { Event::Start(Tag::Link { link_type, dest_url, title, id }) => { Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id }) } Event::Start(Tag::Image { link_type, dest_url, title, id }) => { Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id }) } other => other, }); let mut out = String::with_capacity(text.len() * 2); html::push_html(&mut out, parser); let mut builder = ammonia::Builder::default(); builder.add_generic_attributes(["id", "class"]); builder.add_tag_attributes("input", ["type", "checked", "disabled"]); builder.add_tags(["input"]); builder.link_rel(Some("noopener noreferrer")); builder.clean(&out).to_string() } /// Leave absolute URLs, fragments and mail links alone; rebase the rest. fn rebase(url: &str, base: &str) -> String { if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") { return url.to_string(); } let url = url.strip_prefix("./").unwrap_or(url); format!("{base}{url}") } #[cfg(test)] mod tests { use super::*; #[test] fn renders_and_sanitises() { let html = render( "# Hi\n\nSee [docs](docs/x.md) and  \n\n- [ ] task\n", "/a/b/file/main/", "/a/b/file/main/", ); assert!(html.contains("