Skip to main content

scrybe_render/
html.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Markdown-to-HTML rendering with syntax highlighting.
5
6use std::sync::OnceLock;
7
8use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
9use syntect::easy::HighlightLines;
10use syntect::highlighting::ThemeSet;
11use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
12use syntect::parsing::SyntaxSet;
13
14use scrybe_core::Document;
15
16use crate::math::{extract_math, inject_math};
17use crate::mermaid::inject_mermaid_wrappers;
18use crate::theme::Theme;
19use crate::RenderOutput;
20
21static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
22static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
23
24fn syntax_set() -> &'static SyntaxSet {
25    SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
26}
27
28fn theme_set() -> &'static ThemeSet {
29    THEME_SET.get_or_init(ThemeSet::load_defaults)
30}
31
32/// Renders a [`Document`] to HTML using the given [`Theme`].
33///
34/// The returned [`RenderOutput`] contains:
35/// - `html`: the full fragment with `<style>` prepended.
36/// - `body_html`: just the body content without CSS.
37pub fn render_html(doc: &Document, theme: Theme) -> RenderOutput {
38    // 1. Extract math placeholders before pulldown-cmark sees the source.
39    let (processed_source, math_placeholders) = extract_math(&doc.source);
40
41    // 2. Parse + render with syntax highlighting.
42    let body_html = render_with_highlighting(&processed_source);
43
44    // 3. Re-inject math elements.
45    let body_html = inject_math(&body_html, &math_placeholders);
46
47    // 4. Post-process Mermaid blocks.
48    let body_html = inject_mermaid_wrappers(&body_html);
49
50    // 5. Prepend theme CSS.
51    let html = format!("<style>{}</style>\n{}", theme.css(), body_html);
52
53    RenderOutput { html, body_html }
54}
55
56/// Runs pulldown-cmark with custom syntax-highlighted code blocks.
57fn render_with_highlighting(source: &str) -> String {
58    let opts = Options::all();
59    let parser = Parser::new_ext(source, opts);
60
61    let mut output = String::new();
62    let mut in_code_block = false;
63    let mut current_lang: Option<String> = None;
64    let mut code_buf = String::new();
65    // Events between Start(Image) and its matching End(Image) must reach
66    // push_html as ONE sequence: the HTML writer drains the iterator after
67    // Start(Image) to build the alt attribute. Fed one event at a time it
68    // emits alt="" and the real alt text leaks out as visible body text.
69    // Depth-tracked because alt text may itself contain images.
70    let mut image_buf: Vec<Event> = Vec::new();
71    let mut image_depth: usize = 0;
72
73    for event in parser {
74        match event {
75            Event::Start(Tag::Image { .. }) => {
76                image_depth += 1;
77                image_buf.push(event);
78            }
79            Event::End(TagEnd::Image) => {
80                image_buf.push(event);
81                image_depth -= 1;
82                if image_depth == 0 {
83                    let mut fragment = String::new();
84                    pulldown_cmark::html::push_html(&mut fragment, image_buf.drain(..));
85                    output.push_str(&fragment);
86                }
87            }
88            other if image_depth > 0 => {
89                image_buf.push(other);
90            }
91            Event::Start(Tag::CodeBlock(kind)) => {
92                in_code_block = true;
93                current_lang = match kind {
94                    CodeBlockKind::Fenced(lang) => {
95                        let s = lang.to_string();
96                        if s.is_empty() {
97                            None
98                        } else {
99                            Some(s)
100                        }
101                    }
102                    CodeBlockKind::Indented => None,
103                };
104                code_buf.clear();
105            }
106            Event::End(TagEnd::CodeBlock) => {
107                in_code_block = false;
108                let highlighted = highlight_code(&code_buf, current_lang.as_deref());
109                output.push_str(&highlighted);
110                current_lang = None;
111                code_buf.clear();
112            }
113            Event::Text(text) if in_code_block => {
114                code_buf.push_str(&text);
115            }
116            other => {
117                // For all non-code-block events, let pulldown-cmark render them.
118                let mut fragment = String::new();
119                pulldown_cmark::html::push_html(&mut fragment, std::iter::once(other));
120                output.push_str(&fragment);
121            }
122        }
123    }
124
125    output
126}
127
128/// Produces a highlighted `<pre><code>` block for the given `code` and optional `lang`.
129fn highlight_code(code: &str, lang: Option<&str>) -> String {
130    // Mermaid blocks must not be syntax-highlighted — Mermaid.js needs raw source,
131    // not syntect's span-wrapped output.
132    if lang == Some("mermaid") {
133        let escaped = code
134            .replace('&', "&amp;")
135            .replace('<', "&lt;")
136            .replace('>', "&gt;");
137        return format!(
138            r#"<pre class="code-block"><code class="language-mermaid">{escaped}</code></pre>"#
139        );
140    }
141
142    let ss = syntax_set();
143    let ts = theme_set();
144
145    let syntax = lang
146        .and_then(|l| ss.find_syntax_by_token(l))
147        .unwrap_or_else(|| ss.find_syntax_plain_text());
148
149    let syntect_theme = ts
150        .themes
151        .get("InspiredGitHub")
152        .or_else(|| ts.themes.values().next())
153        .expect("syntect ships at least one theme");
154
155    let mut h = HighlightLines::new(syntax, syntect_theme);
156
157    let lang_class = lang
158        .map(|l| format!(r#" class="language-{l}""#))
159        .unwrap_or_default();
160
161    let mut html = format!(r#"<pre class="code-block"><code{lang_class}>"#);
162
163    for line in syntect::util::LinesWithEndings::from(code) {
164        let ranges = h.highlight_line(line, ss).unwrap_or_default();
165        let highlighted = styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
166            .unwrap_or_else(|_| {
167                // Fallback: HTML-escape the raw line.
168                line.replace('&', "&amp;")
169                    .replace('<', "&lt;")
170                    .replace('>', "&gt;")
171            });
172        html.push_str(&highlighted);
173    }
174
175    html.push_str("</code></pre>\n");
176    html
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn doc(src: &str) -> Document {
184        Document::new(src)
185    }
186
187    // --- existing tests ---
188
189    #[test]
190    fn test_render_heading() {
191        let out = render_html(&doc("# Hello Scrybe"), Theme::Default);
192        assert!(out.html.contains("<h1>"));
193        assert!(out.html.contains("Hello Scrybe"));
194    }
195
196    #[test]
197    fn test_render_empty() {
198        let out = render_html(&doc(""), Theme::Default);
199        // body_html should be empty or at least not an error
200        assert!(!out.body_html.contains("Error"));
201    }
202
203    // --- new tests ---
204
205    #[test]
206    fn test_syntax_highlighting_rust() {
207        let md = "```rust\nfn main() {}\n```\n";
208        let out = render_html(&doc(md), Theme::Default);
209        // syntect emits <span> elements
210        assert!(
211            out.body_html.contains("<span"),
212            "expected <span elements from syntect, got: {}",
213            &out.body_html[..out.body_html.len().min(400)]
214        );
215    }
216
217    #[test]
218    fn test_syntax_highlighting_unknown_lang() {
219        // Unknown language must not panic and must produce a code block.
220        let md = "```xyzzy-nonexistent\nsome code\n```\n";
221        let out = render_html(&doc(md), Theme::Default);
222        assert!(out.body_html.contains("some code"));
223    }
224
225    #[test]
226    fn test_math_inline_extracted() {
227        let out = render_html(&doc("Here is $x^2$ inline."), Theme::Default);
228        assert!(
229            out.body_html.contains(r#"class="math-inline""#),
230            "body_html: {}",
231            out.body_html
232        );
233    }
234
235    #[test]
236    fn test_math_block_extracted() {
237        let out = render_html(&doc("$$\\int f$$"), Theme::Default);
238        assert!(
239            out.body_html.contains(r#"class="math-block""#),
240            "body_html: {}",
241            out.body_html
242        );
243    }
244
245    #[test]
246    fn test_mermaid_wrapper() {
247        let md = "```mermaid\ngraph TD; A-->B;\n```\n";
248        let out = render_html(&doc(md), Theme::Default);
249        assert!(
250            out.body_html.contains(r#"class="mermaid""#),
251            "body_html: {}",
252            out.body_html
253        );
254        assert!(!out.body_html.contains("<pre>"));
255    }
256
257    #[test]
258    fn test_image_alt_and_title_attributes() {
259        // Guard the alt/title contract end-to-end: the bracket text is the
260        // alt attribute; the Markdown title attribute is emitted as title.
261        let out = render_html(
262            &doc("![diagram](image.png \"Architecture\")\n"),
263            Theme::Default,
264        );
265        assert!(
266            out.body_html.contains(r#"alt="diagram""#),
267            "body_html: {}",
268            out.body_html
269        );
270        assert!(
271            out.body_html.contains(r#"title="Architecture""#),
272            "body_html: {}",
273            out.body_html
274        );
275    }
276
277    #[test]
278    fn test_image_empty_alt_no_title() {
279        // A decorative image keeps its (meaningful) empty alt and gains no
280        // title attribute.
281        let out = render_html(&doc("![](decorative.png)\n"), Theme::Default);
282        assert!(
283            out.body_html.contains(r#"alt="""#),
284            "body_html: {}",
285            out.body_html
286        );
287        assert!(
288            !out.body_html.contains("title="),
289            "body_html: {}",
290            out.body_html
291        );
292    }
293
294    #[test]
295    fn test_theme_css_injected() {
296        let out = render_html(&doc("# hi"), Theme::Default);
297        assert!(
298            out.html.contains("<style>"),
299            "html should contain <style> tag"
300        );
301        // body_html should not contain the style tag
302        assert!(!out.body_html.starts_with("<style>"));
303    }
304}