Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/web/src/markdown.rs +116 −8
| @@ -2,16 +2,23 @@ | |||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | 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 | 7 | //! `comrak` with the GFM extensions renders the source; a |
| 8 | //! the result, stripping raw HTML and any scripting so a README can never inject | 8 | //! [`SyntaxHighlighterAdapter`] runs fenced code blocks through the `highlight` |
| 9 | //! markup into the page. Relative-link rewriting and fenced-code highlighting are | 9 | //! crate so they get the same `hl-*` theme classes as the blob viewer; then |
| 10 | //! deferred to the polish phase. | 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 | use maud::{Markup, PreEscaped}; | 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 | #[must_use] | 22 | #[must_use] |
| 16 | pub fn render(source: &str) -> Markup { | 23 | pub fn render(source: &str) -> Markup { |
| 17 | let mut options = comrak::Options::default(); | 24 | let mut options = comrak::Options::default(); |
| @@ -21,11 +28,95 @@ pub fn render(source: &str) -> Markup { | |||
| 21 | options.extension.autolink = true; | 28 | options.extension.autolink = true; |
| 22 | options.extension.footnotes = true; | 29 | options.extension.footnotes = true; |
| 23 | 30 | ||
| 24 | let unsafe_html = comrak::markdown_to_html(source, &options); | 31 | let adapter = Highlighter; |
| 25 | let safe = ammonia::clean(&unsafe_html); | 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 | PreEscaped(safe) | 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 | #[cfg(test)] | 120 | #[cfg(test)] |
| 30 | mod tests { | 121 | mod tests { |
| 31 | use super::*; | 122 | use super::*; |
| @@ -47,4 +138,21 @@ mod tests { | |||
| 47 | let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string(); | 138 | let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string(); |
| 48 | assert!(html.contains("<table>")); | 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 | } |