1use 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
32pub fn render_html(doc: &Document, theme: Theme) -> RenderOutput {
38 let (processed_source, math_placeholders) = extract_math(&doc.source);
40
41 let body_html = render_with_highlighting(&processed_source);
43
44 let body_html = inject_math(&body_html, &math_placeholders);
46
47 let body_html = inject_mermaid_wrappers(&body_html);
49
50 let html = format!("<style>{}</style>\n{}", theme.css(), body_html);
52
53 RenderOutput { html, body_html }
54}
55
56fn 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 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 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
128fn highlight_code(code: &str, lang: Option<&str>) -> String {
130 if lang == Some("mermaid") {
133 let escaped = code
134 .replace('&', "&")
135 .replace('<', "<")
136 .replace('>', ">");
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 line.replace('&', "&")
169 .replace('<', "<")
170 .replace('>', ">")
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 #[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 assert!(!out.body_html.contains("Error"));
201 }
202
203 #[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 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 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 let out = render_html(
262 &doc("\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 let out = render_html(&doc("\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 assert!(!out.body_html.starts_with("<style>"));
303 }
304}