fabrica

hanna/fabrica

feat(web): syntax-highlight fenced code blocks in markdown

8263389 · hanna committed on 2026-07-25

Bridge comrak's code-fence rendering to the highlight crate via a
SyntaxHighlighterAdapter, so README/bio code blocks get the same hl-* theme
classes as the blob viewer. The ammonia sanitizer is extended to permit only
those highlight classes; unknown or plain fences fall back to escaped text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
1 files changed · +116 −8UnifiedSplit
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 this2// 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/.
44
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 sanitizes7//! `comrak` with the GFM extensions renders the source; a
8//! the result, stripping raw HTML and any scripting so a README can never inject8//! [`SyntaxHighlighterAdapter`] runs fenced code blocks through the `highlight`
9//! markup into the page. Relative-link rewriting and fenced-code highlighting are9//! 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.
1112
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::fmt::{self, Write as _};
16use std::path::Path as FsPath;
17
18use comrak::adapters::SyntaxHighlighterAdapter;
12use maud::{Markup, PreEscaped};19use maud::{Markup, PreEscaped};
1320
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]
16pub fn render(source: &str) -> Markup {23pub 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;
2330
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}
2839
40/// The ammonia sanitizer, extended to allow the highlighter's `<span class="hl-*">`
41/// runs (and only those classes) inside code blocks.
42fn 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.
51struct Highlighter;
52
53impl 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.
103struct Escape<'a>(&'a str);
104
105impl 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("&amp;")?,
110 '<' => f.write_str("&lt;")?,
111 '>' => f.write_str("&gt;")?,
112 '"' => f.write_str("&quot;")?,
113 _ => f.write_char(c)?,
114 }
115 }
116 Ok(())
117 }
118}
119
29#[cfg(test)]120#[cfg(test)]
30mod tests {121mod 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("&lt;b&gt;"), "code is escaped: {html}");
157 }
50}158}