//! 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 }) => {
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("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("