Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/web/src/markdown.rs +116 −8
| @@ -2,16 +2,23 @@ | |||
| 2 | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | - | //! Markdown rendering for READMEs. | |
| 5 | + | //! Markdown rendering for READMEs and bios. | |
| 6 | 6 | //! | |
| 7 | - | //! `comrak` with the GFM extensions renders the source; `ammonia` then sanitizes | |
| 8 | - | //! the result, stripping raw HTML and any scripting so a README can never inject | |
| 9 | - | //! markup into the page. Relative-link rewriting and fenced-code highlighting are | |
| 10 | - | //! deferred to the polish phase. | |
| 7 | + | //! `comrak` with the GFM extensions renders the source; a | |
| 8 | + | //! [`SyntaxHighlighterAdapter`] runs fenced code blocks through the `highlight` | |
| 9 | + | //! crate so they get the same `hl-*` theme classes as the blob viewer; then | |
| 10 | + | //! `ammonia` sanitizes the result, stripping raw HTML and scripting while | |
| 11 | + | //! permitting only our highlight classes, so a README can never inject markup. | |
| 11 | 12 | ||
| 13 | + | use std::borrow::Cow; | |
| 14 | + | use std::collections::HashMap; | |
| 15 | + | use std::fmt::{self, Write as _}; | |
| 16 | + | use std::path::Path as FsPath; | |
| 17 | + | ||
| 18 | + | use comrak::adapters::SyntaxHighlighterAdapter; | |
| 12 | 19 | use maud::{Markup, PreEscaped}; | |
| 13 | 20 | ||
| 14 | - | /// Render `source` markdown to sanitized HTML, ready to splice into a page. | |
| 21 | + | /// Render `source` markdown to sanitized, syntax-highlighted HTML. | |
| 15 | 22 | #[must_use] | |
| 16 | 23 | pub fn render(source: &str) -> Markup { | |
| 17 | 24 | let mut options = comrak::Options::default(); | |
| @@ -21,11 +28,95 @@ pub fn render(source: &str) -> Markup { | |||
| 21 | 28 | options.extension.autolink = true; | |
| 22 | 29 | options.extension.footnotes = true; | |
| 23 | 30 | ||
| 24 | - | let unsafe_html = comrak::markdown_to_html(source, &options); | |
| 25 | - | let safe = ammonia::clean(&unsafe_html); | |
| 31 | + | let adapter = Highlighter; | |
| 32 | + | let mut plugins = comrak::options::Plugins::default(); | |
| 33 | + | plugins.render.codefence_syntax_highlighter = Some(&adapter); | |
| 34 | + | ||
| 35 | + | let unsafe_html = comrak::markdown_to_html_with_plugins(source, &options, &plugins); | |
| 36 | + | let safe = sanitizer().clean(&unsafe_html).to_string(); | |
| 26 | 37 | PreEscaped(safe) | |
| 27 | 38 | } | |
| 28 | 39 | ||
| 40 | + | /// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">` | |
| 41 | + | /// runs (and only those classes) inside code blocks. | |
| 42 | + | fn sanitizer() -> ammonia::Builder<'static> { | |
| 43 | + | let mut builder = ammonia::Builder::default(); | |
| 44 | + | builder.add_tags(["span"]); | |
| 45 | + | builder.add_allowed_classes("span", highlight::HL_CLASSES.iter().copied()); | |
| 46 | + | builder.add_allowed_classes("code", ["highlight"]); | |
| 47 | + | builder | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Bridges comrak's code-fence rendering to the `highlight` crate. | |
| 51 | + | struct Highlighter; | |
| 52 | + | ||
| 53 | + | impl SyntaxHighlighterAdapter for Highlighter { | |
| 54 | + | fn write_highlighted( | |
| 55 | + | &self, | |
| 56 | + | output: &mut dyn fmt::Write, | |
| 57 | + | lang: Option<&str>, | |
| 58 | + | code: &str, | |
| 59 | + | ) -> fmt::Result { | |
| 60 | + | // The info string is a language hint (like a `.gitattributes` override); | |
| 61 | + | // resolve it, then highlight. Unknown/plain langs fall back to escaped text. | |
| 62 | + | let hint = lang.map(str::trim).filter(|l| !l.is_empty()); | |
| 63 | + | let resolved = highlight::resolve(FsPath::new(""), code.as_bytes(), hint); | |
| 64 | + | let lines = highlight::highlight_document(&resolved, code); | |
| 65 | + | for (i, line) in lines.iter().enumerate() { | |
| 66 | + | if i > 0 { | |
| 67 | + | output.write_char('\n')?; | |
| 68 | + | } | |
| 69 | + | for span in &line.spans { | |
| 70 | + | match span.class { | |
| 71 | + | Some(class) => { | |
| 72 | + | write!( | |
| 73 | + | output, | |
| 74 | + | "<span class=\"{class}\">{}</span>", | |
| 75 | + | Escape(&span.text) | |
| 76 | + | )?; | |
| 77 | + | } | |
| 78 | + | None => write!(output, "{}", Escape(&span.text))?, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | Ok(()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | fn write_pre_tag( | |
| 86 | + | &self, | |
| 87 | + | output: &mut dyn fmt::Write, | |
| 88 | + | _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 89 | + | ) -> fmt::Result { | |
| 90 | + | output.write_str("<pre>") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn write_code_tag( | |
| 94 | + | &self, | |
| 95 | + | output: &mut dyn fmt::Write, | |
| 96 | + | _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 97 | + | ) -> fmt::Result { | |
| 98 | + | output.write_str("<code class=\"highlight\">") | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// A `Display` wrapper that HTML-escapes text as it is written. | |
| 103 | + | struct Escape<'a>(&'a str); | |
| 104 | + | ||
| 105 | + | impl fmt::Display for Escape<'_> { | |
| 106 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 107 | + | for c in self.0.chars() { | |
| 108 | + | match c { | |
| 109 | + | '&' => f.write_str("&")?, | |
| 110 | + | '<' => f.write_str("<")?, | |
| 111 | + | '>' => f.write_str(">")?, | |
| 112 | + | '"' => f.write_str(""")?, | |
| 113 | + | _ => f.write_char(c)?, | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | Ok(()) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 29 | 120 | #[cfg(test)] | |
| 30 | 121 | mod tests { | |
| 31 | 122 | use super::*; | |
| @@ -47,4 +138,21 @@ mod tests { | |||
| 47 | 138 | let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string(); | |
| 48 | 139 | assert!(html.contains("<table>")); | |
| 49 | 140 | } | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn highlights_fenced_code() { | |
| 144 | + | let html = render("```rust\nfn main() {}\n```").into_string(); | |
| 145 | + | // A keyword span survives sanitizing with its highlight class. | |
| 146 | + | assert!( | |
| 147 | + | html.contains("hl-keyword"), | |
| 148 | + | "fenced rust should be highlighted: {html}" | |
| 149 | + | ); | |
| 150 | + | assert!(!html.contains("<script")); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn plain_fence_is_escaped_not_highlighted() { | |
| 155 | + | let html = render("```\n<b>x</b> & y\n```").into_string(); | |
| 156 | + | assert!(html.contains("<b>"), "code is escaped: {html}"); | |
| 157 | + | } | |
| 50 | 158 | } | |