,
}
impl Links {
/// Cache-key fragment identifying everything the output depends on.
pub fn key(&self) -> String {
format!(
"{}\0{}\0{}",
self.repo_url,
self.rev,
String::from_utf8_lossy(&self.dir)
)
}
}
/// Is this a Markdown file (by name)?
pub fn is_markdown(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
[".md", ".markdown", ".mdown", ".mkd", ".mkdn"]
.iter()
.any(|ext| lower.ends_with(ext))
}
pub fn render(markdown: &str, links: &Links) -> String {
let mut options = Options::default();
let ext = &mut options.extension;
ext.strikethrough = true;
ext.table = true;
ext.autolink = true;
ext.tasklist = true;
ext.footnotes = true;
ext.alerts = true;
ext.header_id_prefix = Some(String::new());
ext.front_matter_delimiter = Some("---".into());
options.render.r#unsafe = true;
options.render.github_pre_lang = false;
let mut plugins = Plugins::default();
let highlighter = FenceHighlighter;
plugins.render.codefence_syntax_highlighter = Some(&highlighter);
let html = markdown_to_html_with_plugins(markdown, &options, &plugins);
sanitize(&html, links.clone())
}
/// Plain text (non-Markdown READMEs) as an escaped ``.
pub fn render_plain(text: &str) -> String {
let mut out = String::from("");
escape_html(&mut out, text);
out.push_str("");
out
}
fn sanitize(html: &str, links: Links) -> String {
let mut builder = ammonia::Builder::default();
builder
.add_tags(["input"])
.add_tag_attributes("input", ["checked"])
.set_tag_attribute_value("input", "type", "checkbox")
.set_tag_attribute_value("input", "disabled", "")
.add_generic_attributes(["id", "align"])
.add_tag_attributes("a", ["aria-hidden"])
.add_tag_attributes("img", ["title"])
.add_tag_attributes("ol", ["start"])
.add_tag_attributes("section", ["data-footnotes"])
.add_tags(["section"])
.link_rel(Some("noopener noreferrer nofollow"))
.allowed_classes(allowed_classes())
.url_relative(ammonia::UrlRelative::PassThrough)
.attribute_filter(move |element, attribute, value| {
match (element, attribute) {
("a", "href") => rewrite(&links, value, "blob"),
("img", "src") => rewrite(&links, value, "raw"),
_ => None,
}
.map_or(Some(Cow::Borrowed(value)), |v| Some(Cow::Owned(v)))
});
builder.clean(html).to_string()
}
fn allowed_classes() -> HashMap<&'static str, HashSet<&'static str>> {
let set = |items: &[&'static str]| items.iter().copied().collect::>();
let alerts = [
"markdown-alert",
"markdown-alert-note",
"markdown-alert-tip",
"markdown-alert-important",
"markdown-alert-warning",
"markdown-alert-caution",
];
HashMap::from([
("span", set(&["hl-k", "hl-d", "hl-s", "hl-c", "hl-x"])),
("div", set(&alerts)),
("p", set(&["markdown-alert-title"])),
("a", set(&["anchor"])),
("section", set(&["footnotes"])),
("ul", set(&["contains-task-list"])),
("li", set(&["task-list-item"])),
])
}
/// `scheme:` per the URL spec, before any `/`, `?` or `#`.
fn has_scheme(url: &str) -> bool {
let Some(colon) = url.find(':') else {
return false;
};
let scheme = &url[..colon];
scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
/// Rewrite a repository-relative URL; `None` leaves the value unchanged.
fn rewrite(links: &Links, url: &str, view: &str) -> Option {
if url.is_empty() || url.starts_with('#') || url.starts_with("//") || has_scheme(url) {
return None;
}
let (rest, fragment) = match url.split_once('#') {
Some((rest, f)) => (rest, Some(f)),
None => (url, None),
};
let path = rest.split_once('?').map_or(rest, |(p, _)| p);
let path = percent_decode(path);
let mut parts: Vec<&[u8]> = Vec::new();
if !path.starts_with(b"/") {
parts.extend(links.dir.split(|b| *b == b'/').filter(|p| !p.is_empty()));
}
for part in path.split(|b| *b == b'/') {
match part {
b"" | b"." => {}
b".." => {
parts.pop();
}
part => parts.push(part),
}
}
let joined = parts.join(&b'/');
let mut out = if joined.is_empty() {
format!("{}/tree/{}", links.repo_url, links.rev)
} else {
format!(
"{}/{view}/{}/{}",
links.repo_url,
links.rev,
encode_bytes(&joined)
)
};
if let Some(fragment) = fragment {
out.push('#');
out.push_str(fragment);
}
Some(out)
}
/// Highlights fenced code blocks with the same classes as the blob view.
struct FenceHighlighter;
impl SyntaxHighlighterAdapter for FenceHighlighter {
fn write_highlighted(
&self,
output: &mut dyn fmt::Write,
lang: Option<&str>,
code: &str,
) -> fmt::Result {
match lang.and_then(highlight::for_token) {
Some(syntax) => output.write_str(&highlight::lines(code, syntax).join("\n")),
None => {
let mut escaped = String::with_capacity(code.len());
escape_html(&mut escaped, code.trim_end_matches('\n'));
output.write_str(&escaped)
}
}
}
fn write_pre_tag(
&self,
output: &mut dyn fmt::Write,
_attributes: HashMap<&'static str, Cow<'_, str>>,
) -> fmt::Result {
output.write_str("")
}
fn write_code_tag(
&self,
output: &mut dyn fmt::Write,
_attributes: HashMap<&'static str, Cow<'_, str>>,
) -> fmt::Result {
output.write_str("")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn links(dir: &str) -> Links {
Links {
repo_url: "/r".into(),
rev: "main".into(),
dir: dir.as_bytes().to_vec(),
}
}
#[test]
fn strips_dangerous_html() {
let html = render(
"\n\n
\n\n\
[x](javascript:alert(1)) d\n\n\
\n\n\
s
",
&links(""),
);
for bad in [
"