A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.
axumdockergitgit-browsergitoxidegixrustself-hosted
1//! Markdown rendering for READMEs. pulldown-cmark with tables, footnotes,
2//! strikethrough, and task-list extensions enabled (the subset Github lets
3//! you use in a README), then run through ammonia so any inline <script>
4//! or other dangerous HTML in the markdown source is stripped.
5
6use pulldown_cmark::{CowStr, Event, Options, Parser, Tag};
7
8/// Render a README's markdown to sanitized HTML. Relative link targets are
9/// prefixed with `link_base` (typically `/<name>/blob/<branch>`) and relative
10/// image targets with `image_base` (typically `/<name>/raw/<branch>`) so they
11/// resolve against the repo instead of the page's URL.
12pub fn render(input: &str, link_base: &str, image_base: &str) -> String {
13 let mut opts = Options::empty();
14 opts.insert(Options::ENABLE_TABLES);
15 opts.insert(Options::ENABLE_FOOTNOTES);
16 opts.insert(Options::ENABLE_STRIKETHROUGH);
17 opts.insert(Options::ENABLE_TASKLISTS);
18 opts.insert(Options::ENABLE_SMART_PUNCTUATION);
19 let parser = Parser::new_ext(input, opts).map(|event| match event {
20 Event::Start(Tag::Link { link_type, dest_url, title, id }) => Event::Start(Tag::Link {
21 link_type,
22 dest_url: rewrite(&dest_url, link_base),
23 title,
24 id,
25 }),
26 Event::Start(Tag::Image { link_type, dest_url, title, id }) => Event::Start(Tag::Image {
27 link_type,
28 dest_url: rewrite(&dest_url, image_base),
29 title,
30 id,
31 }),
32 other => other,
33 });
34 let mut html = String::with_capacity(input.len());
35 pulldown_cmark::html::push_html(&mut html, parser);
36 ammonia::clean(&html)
37}
38
39/// Prefix a URL with `base` if it's a relative path (i.e. not absolute,
40/// protocol-relative, scheme-bearing, or a bare fragment).
41fn rewrite<'a>(url: &CowStr<'a>, base: &str) -> CowStr<'a> {
42 if url.is_empty()
43 || url.starts_with('/')
44 || url.starts_with('#')
45 || url.starts_with("//")
46 || has_scheme(url)
47 {
48 return url.clone();
49 }
50 let mut path: &str = url;
51 while let Some(rest) = path.strip_prefix("./") {
52 path = rest;
53 }
54 CowStr::from(format!("{}/{}", base.trim_end_matches('/'), path))
55}
56
57/// True if `url` begins with an RFC 3986 scheme (`http:`, `mailto:`, etc.).
58fn has_scheme(url: &str) -> bool {
59 let bytes = url.as_bytes();
60 if bytes.first().is_none_or(|b| !b.is_ascii_alphabetic()) {
61 return false;
62 }
63 for (i, &b) in bytes.iter().enumerate().skip(1) {
64 match b {
65 b':' => return i > 0,
66 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'.' | b'-' => continue,
67 _ => return false,
68 }
69 }
70 false
71}