// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Markdown rendering for READMEs and bios. //! //! `comrak` with the GFM extensions renders the source; a //! [`SyntaxHighlighterAdapter`] runs fenced code blocks through the `highlight` //! crate so they get the same `hl-*` theme classes as the blob viewer; then //! `ammonia` sanitizes the result, stripping raw HTML and scripting while //! permitting only our highlight classes, so a README can never inject markup. use std::borrow::Cow; use std::collections::HashMap; use std::fmt::{self, Write as _}; use std::path::Path as FsPath; use comrak::adapters::SyntaxHighlighterAdapter; use maud::{Markup, PreEscaped}; /// Render `source` markdown to sanitized, syntax-highlighted HTML. #[must_use] pub fn render(source: &str) -> Markup { PreEscaped(render_html(source, None)) } /// Like [`render`], but also linkifies `#123` issue/PR references against /// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments. #[must_use] pub fn render_with_refs(source: &str, repo_base: &str) -> Markup { PreEscaped(linkify_refs(&render_html(source, None), repo_base)) } /// Render a README or in-repo markdown file, rewriting **relative** links and /// images so they point into the repository at `ref_name`. `base` is /// `/owner/repo`; `dir` is the directory the document lives in (empty for the /// repo root), used to resolve `./` and `../` paths. Also linkifies `#123` /// references. Absolute URLs, `//host`, `mailto:`, and `#anchor` are left alone. #[must_use] pub fn render_repo(source: &str, base: &str, ref_name: &str, dir: &str) -> Markup { let repo = RepoUrls { base, ref_name, dir, }; PreEscaped(linkify_refs(&render_html(source, Some(&repo)), base)) } /// The context for rewriting a repository document's relative URLs. struct RepoUrls<'a> { base: &'a str, ref_name: &'a str, dir: &'a str, } impl RepoUrls<'_> { /// Rewrite `url` to point into the repo, or `None` to leave it unchanged. /// Links target the blob view; images target the raw view. fn resolve(&self, url: &str, image: bool) -> Option { let url = url.trim(); if url.is_empty() || url.starts_with('#') || url.starts_with("//") || url.starts_with("mailto:") { return None; } // A scheme (`https:`, `data:`, …) marks an absolute URL — leave it be. if let Some(idx) = url.find(':') && url[..idx] .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) && !url[..idx].is_empty() { return None; } // Split off a trailing `?query` / `#fragment` to reattach after resolving. let (path, suffix) = url .find(['?', '#']) .map_or((url, ""), |i| (&url[..i], &url[i..])); let joined = if let Some(root_rel) = path.strip_prefix('/') { root_rel.to_string() } else if self.dir.is_empty() { path.to_string() } else { format!("{}/{path}", self.dir) }; let resolved = normalize_path(&joined); let view = if image { "raw" } else { "blob" }; Some(format!( "{}/-/{view}/{}/{resolved}{suffix}", self.base, self.ref_name )) } } /// Resolve `.`/`..`/empty segments in a slash path (no leading slash on output). fn normalize_path(path: &str) -> String { let mut out: Vec<&str> = Vec::new(); for seg in path.split('/') { match seg { "" | "." => {} ".." => { out.pop(); } other => out.push(other), } } out.join("/") } /// Rewrite every relative link and image URL in the AST subtree. fn rewrite_urls<'a>(node: &'a comrak::nodes::AstNode<'a>, repo: &RepoUrls) { for child in node.children() { rewrite_urls(child, repo); } let mut data = node.data.borrow_mut(); match &mut data.value { comrak::nodes::NodeValue::Link(link) => { if let Some(url) = repo.resolve(&link.url, false) { link.url = url; } } comrak::nodes::NodeValue::Image(link) => { if let Some(url) = repo.resolve(&link.url, true) { link.url = url; } } _ => {} } } /// The shared comrak + ammonia pipeline, producing sanitized HTML. When `repo` /// is set, relative links/images are rewritten into the repository first. fn render_html(source: &str, repo: Option<&RepoUrls>) -> String { let mut options = comrak::Options::default(); options.extension.table = true; options.extension.strikethrough = true; options.extension.tasklist = true; options.extension.autolink = true; options.extension.footnotes = true; let adapter = Highlighter; let mut plugins = comrak::options::Plugins::default(); plugins.render.codefence_syntax_highlighter = Some(&adapter); let unsafe_html = match repo { None => comrak::markdown_to_html_with_plugins(source, &options, &plugins), Some(repo) => { let arena = comrak::Arena::new(); let root = comrak::parse_document(&arena, source, &options); rewrite_urls(root, repo); let mut out = String::new(); let _ = comrak::format_html_with_plugins(root, &options, &mut out, &plugins); out } }; sanitizer().clean(&unsafe_html).to_string() } /// Turn `#123` into a link to the issue/PR, but only in ordinary text — never /// inside a tag, an attribute, or a ``/`
` block. Runs on already
/// sanitized HTML, so the injected anchors are trusted. The link targets the
/// issues path; the issue view redirects to the PR view when the number is a PR.
fn linkify_refs(html: &str, base: &str) -> String {
    let bytes = html.as_bytes();
    // Build as bytes so multibyte UTF-8 is copied verbatim (only ASCII markers
    // are special-cased).
    let mut out: Vec = Vec::with_capacity(html.len() + 32);
    let mut i = 0;
    let mut in_tag = false;
    let mut code_depth: usize = 0;
    let mut a_depth: usize = 0; // inside an existing  (or email autolink)
    while i < bytes.len() {
        let c = bytes[i];
        if in_tag {
            out.push(c);
            if c == b'>' {
                in_tag = false;
            }
            i += 1;
            continue;
        }
        if c == b'<' {
            let rest = &html[i..];
            if rest.starts_with(" i + 1 {
                let num = &html[i + 1..j];
                out.extend_from_slice(
                    format!("#{num}")
                        .as_bytes(),
                );
                i = j;
                continue;
            }
        }
        // `@name` → user profile link (boundary avoids matching email local parts).
        if plain && boundary && c == b'@' {
            let mut j = i + 1;
            while j < bytes.len() && is_username_byte(bytes[j]) {
                j += 1;
            }
            if j > i + 1 {
                let name = &html[i + 1..j];
                out.extend_from_slice(
                    format!("@{name}").as_bytes(),
                );
                i = j;
                continue;
            }
        }
        out.push(c);
        i += 1;
    }
    String::from_utf8(out).unwrap_or_else(|_| html.to_string())
}

/// Bytes allowed in a username (matches `model::validate_name`'s character set).
fn is_username_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}

/// Extract `@username` mentions from raw markdown text, deduplicated. Used to
/// create mention notifications (over-matching inside code is acceptable).
#[must_use]
pub fn mentions(text: &str) -> Vec {
    let bytes = text.as_bytes();
    let mut out: Vec = Vec::new();
    let mut i = 0;
    while i < bytes.len() {
        let boundary = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
        if boundary && bytes[i] == b'@' {
            let mut j = i + 1;
            while j < bytes.len() && is_username_byte(bytes[j]) {
                j += 1;
            }
            if j > i + 1 {
                let name = text[i + 1..j].to_string();
                if !out.contains(&name) {
                    out.push(name);
                }
                i = j;
                continue;
            }
        }
        i += 1;
    }
    out
}

/// The ammonia sanitizer, extended to allow the highlighter's ``
/// runs (and only those classes) inside code blocks.
fn sanitizer() -> ammonia::Builder<'static> {
    let mut builder = ammonia::Builder::default();
    builder.add_tags(["span"]);
    builder.add_allowed_classes("span", highlight::HL_CLASSES.iter().copied());
    builder.add_allowed_classes("code", ["highlight"]);
    builder
}

/// Bridges comrak's code-fence rendering to the `highlight` crate.
struct Highlighter;

impl SyntaxHighlighterAdapter for Highlighter {
    fn write_highlighted(
        &self,
        output: &mut dyn fmt::Write,
        lang: Option<&str>,
        code: &str,
    ) -> fmt::Result {
        // The info string is a language hint (like a `.gitattributes` override);
        // resolve it, then highlight. Unknown/plain langs fall back to escaped text.
        let hint = lang.map(str::trim).filter(|l| !l.is_empty());
        let resolved = highlight::resolve(FsPath::new(""), code.as_bytes(), hint);
        let lines = highlight::highlight_document(&resolved, code);
        for (i, line) in lines.iter().enumerate() {
            if i > 0 {
                output.write_char('\n')?;
            }
            for span in &line.spans {
                match span.class {
                    Some(class) => {
                        write!(
                            output,
                            "{}",
                            Escape(&span.text)
                        )?;
                    }
                    None => write!(output, "{}", Escape(&span.text))?,
                }
            }
        }
        Ok(())
    }

    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("")
    }
}

/// A `Display` wrapper that HTML-escapes text as it is written.
struct Escape<'a>(&'a str);

impl fmt::Display for Escape<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for c in self.0.chars() {
            match c {
                '&' => f.write_str("&")?,
                '<' => f.write_str("<")?,
                '>' => f.write_str(">")?,
                '"' => f.write_str(""")?,
                _ => f.write_char(c)?,
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_gfm_and_strips_scripts() {
        let html =
            render("# Title\n\n- [x] done\n\n\n\n`code`").into_string();
        assert!(html.contains("

")); assert!(html.contains("code")); assert!( !html.contains("")); } #[test] fn linkifies_issue_refs_outside_code() { let html = render_with_refs("see #12 and `#34` here", "/o/r").into_string(); assert!( html.contains(r#"#12"#), "plain #12 links: {html}" ); // The `#34` inside a code span is left alone. assert!(html.contains("#34"), "code #34 untouched: {html}"); assert!( !html.contains("issues/34"), "code #34 must not link: {html}" ); } #[test] fn does_not_link_word_hash() { let html = render_with_refs("color abc#5 def", "/o/r").into_string(); assert!(!html.contains("issues/5"), "abc#5 is not a ref: {html}"); } #[test] fn linkifies_mentions_but_not_emails() { let html = render_with_refs("hi @alice, mail me@example.com", "/o/r").into_string(); assert!( html.contains(r#"@alice"#), "@alice links: {html}" ); // The email's `@example` is not a mention (local part is alphanumeric). assert!( !html.contains(r#"href="/example""#), "email untouched: {html}" ); } #[test] fn extracts_mentions() { assert_eq!(mentions("hey @bob and @carol-x!"), vec!["bob", "carol-x"]); assert_eq!(mentions("foo@bar.com only"), Vec::::new()); // Deduplicated. assert_eq!(mentions("@a @a @b"), vec!["a", "b"]); } #[test] fn rewrites_relative_repo_links_and_images() { let src = "[cfg](fabrica.example.toml) ![logo](docs/logo.png) \ [abs](https://x.test/y) [anchor](#s)"; let html = render_repo(src, "/hanna/fabrica", "main", "").into_string(); // A relative link points at the blob view on the current ref. assert!( html.contains(r#"href="/hanna/fabrica/-/blob/main/fabrica.example.toml""#), "relative link rewritten: {html}" ); // A relative image points at the raw view. assert!( html.contains(r#"src="/hanna/fabrica/-/raw/main/docs/logo.png""#), "relative image rewritten: {html}" ); // Absolute URLs and bare anchors are left untouched. assert!( html.contains(r#"href="https://x.test/y""#), "abs kept: {html}" ); assert!(html.contains(r##"href="#s""##), "anchor kept: {html}"); } #[test] fn resolves_relative_paths_from_the_document_directory() { // A link in docs/guide.md to ../README.md resolves to the repo root. let html = render_repo("[up](../README.md)", "/o/r", "v1.0", "docs").into_string(); assert!( html.contains(r#"href="/o/r/-/blob/v1.0/README.md""#), "../ resolved against dir: {html}" ); } #[test] fn highlights_fenced_code() { let html = render("```rust\nfn main() {}\n```").into_string(); // A keyword span survives sanitizing with its highlight class. assert!( html.contains("hl-keyword"), "fenced rust should be highlighted: {html}" ); assert!(!html.contains("x & y\n```").into_string(); assert!(html.contains("<b>"), "code is escaped: {html}"); } }