//! 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  \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("