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 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // 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.
66 //!
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.
1112
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;
1219 use 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.
1522 #[must_use]
1623 pub fn render(source: &str) -> Markup {
1724 let mut options = comrak::Options::default();
@@ -21,11 +28,95 @@ pub fn render(source: &str) -> Markup {
2128 options.extension.autolink = true;
2229 options.extension.footnotes = true;
2330
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();
2637 PreEscaped(safe)
2738 }
2839
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("&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+
29120 #[cfg(test)]
30121 mod tests {
31122 use super::*;
@@ -47,4 +138,21 @@ mod tests {
47138 let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string();
48139 assert!(html.contains("<table>"));
49140 }
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+ }
50158 }