//! 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}; /// Rendered Markdown by (text, link bases): 32 MiB. fn cache() -> &'static super::cache::Lru { static C: std::sync::OnceLock> = std::sync::OnceLock::new(); C.get_or_init(|| super::cache::Lru::new(32 << 20)) } /// (hits, misses, bytes) of the rendered Markdown cache. pub fn cache_stats() -> (u64, u64, usize) { cache().stats() } /// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to /// files and for images. Cached by the text and both bases. pub fn render(text: &str, base_file: &str, base_raw: &str) -> String { let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]); cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone() } fn render_uncached(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 }) => { let src = raw_image(&dest_url, &origin()).unwrap_or_else(|| rebase(&dest_url, base_raw)); Event::Start(Tag::Image { link_type, dest_url: src.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() } /// This hub's own origin (`https://oxygenhub.io`), so an image written as /// a full address of one of its file pages is still recognised. static ORIGIN: std::sync::RwLock = std::sync::RwLock::new(String::new()); /// Set when the hub is built, from its base URL. pub fn set_origin(base_url: &str) { *ORIGIN.write().unwrap() = base_url.trim_end_matches('/').to_string(); } fn origin() -> String { ORIGIN.read().unwrap().clone() } /// An image that points at one of this hub's file pages /// (`[ORIGIN]/OWNER/NAME/file/REF/PATH`, the address a file's page shows) /// is that file's raw bytes (`…/raw/REF/PATH`): the page is HTML, which an /// `` cannot show, as GitHub turns `blob/` images into raw ones. fn raw_image(url: &str, origin: &str) -> Option { let (host, path) = if url.starts_with('/') && !url.starts_with("//") { ("", url) } else if !origin.is_empty() && url.strip_prefix(origin).is_some_and(|p| p.starts_with('/')) { (origin, &url[origin.len()..]) } else { return None; }; let mut parts = path[1..].splitn(4, '/'); let (owner, name, kind, rest) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?); (kind == "file" && !owner.is_empty() && !name.is_empty() && !rest.is_empty()).then(|| format!("{host}/{owner}/{name}/raw/{rest}")) } /// 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 ![i](img.png) \n\n- [ ] task\n", "/a/b/file/main/", "/a/b/file/main/", ); assert!(html.contains("

Hi

")); assert!(html.contains("href=\"/a/b/file/main/docs/x.md\"")); assert!(html.contains("src=\"/a/b/file/main/img.png\"")); assert!(!html.contains("