1use lazy_static::lazy_static;
2use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd};
3use regex::Regex;
4use syntect::easy::HighlightLines;
5use syntect::highlighting::ThemeSet;
6use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
7use syntect::parsing::SyntaxSet;
8use syntect::util::LinesWithEndings;
9
10#[derive(Debug)]
11struct FenceWarning {
12 line: usize,
13 outer_start: usize,
14 backtick_count: usize,
15}
16
17lazy_static! {
18 static ref EMOJI_REGEX: Regex = Regex::new(r":([a-zA-Z0-9_+-]+):")
19 .expect("Failed to compile EMOJI_REGEX");
20 static ref ALERT_REGEX: Regex = Regex::new(
21 r"(?s)<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*?)</p>(.*?)</blockquote>"
22 ).expect("Failed to compile ALERT_REGEX");
23 static ref HEADING_REGEX: Regex = Regex::new(r"<(h[1-6])>(.*?)</h[1-6]>")
24 .expect("Failed to compile HEADING_REGEX");
25 static ref HTML_TAG_REGEX: Regex = Regex::new(r"<[^>]+>")
26 .expect("Failed to compile HTML_TAG_REGEX");
27 static ref MULTI_HYPHEN_REGEX: Regex = Regex::new(r"-+")
28 .expect("Failed to compile MULTI_HYPHEN_REGEX");
29 static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines();
30 static ref THEME_SET: ThemeSet = ThemeSet::load_defaults();
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct TocItem {
35 pub level: u8,
36 pub id: String,
37 pub text: String,
38}
39
40pub struct MarkdownRenderer {
41 theme: String,
42}
43
44impl MarkdownRenderer {
45 pub fn new(theme: &str) -> Self {
46 Self {
47 theme: theme.to_string(),
48 }
49 }
50
51 pub fn render(&self, markdown: &str) -> (String, bool, Vec<TocItem>) {
52 let mut options = Options::empty();
53 options.insert(Options::ENABLE_TABLES);
54 options.insert(Options::ENABLE_FOOTNOTES);
55 options.insert(Options::ENABLE_STRIKETHROUGH);
56 options.insert(Options::ENABLE_TASKLISTS);
57 options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
58
59 let ss: &SyntaxSet = &SYNTAX_SET;
60 let ts: &ThemeSet = &THEME_SET;
61
62 let theme_name = match self.theme.as_str() {
63 "light" => "Solarized (light)",
64 "dark" => "base16-ocean.dark",
65 _ => "base16-ocean.dark",
66 };
67 let theme = &ts.themes[theme_name];
68
69 let parser = Parser::new_ext(markdown, options);
70 let mut new_events = Vec::new();
71 let mut in_code_block = false;
72 let mut code_lang = String::new();
73 let mut code_buffer = String::new();
74 let mut has_mermaid = false;
75
76 for event in parser {
77 match event {
78 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(fence_lang))) => {
79 in_code_block = true;
80 code_lang = fence_lang.to_string();
81 }
82 Event::Text(text) if in_code_block => {
83 code_buffer.push_str(&text);
84 }
85 Event::End(TagEnd::CodeBlock) => {
86 if in_code_block {
87 if code_lang.to_lowercase() == "mermaid" {
89 has_mermaid = true;
90 let mermaid_html = format!(
91 "<pre class=\"mermaid\">{}</pre>",
92 html_escape::encode_text(&code_buffer)
93 );
94 new_events.push(Event::Html(CowStr::from(mermaid_html)));
95 } else {
96 let syntax = ss
98 .find_syntax_by_extension(&code_lang)
99 .unwrap_or_else(|| ss.find_syntax_plain_text());
100 let mut highlighter = HighlightLines::new(syntax, theme);
101
102 let mut highlighted_html = String::from("<pre><code>");
103 for line in LinesWithEndings::from(&code_buffer) {
104 match highlighter.highlight_line(line, ss) {
105 Ok(ranges) => {
106 match styled_line_to_highlighted_html(
107 &ranges[..],
108 IncludeBackground::No,
109 ) {
110 Ok(escaped) => highlighted_html.push_str(&escaped),
111 Err(_) => highlighted_html
112 .push_str(&html_escape::encode_text(line)),
113 }
114 }
115 Err(_) => {
116 highlighted_html.push_str(&html_escape::encode_text(line))
117 }
118 }
119 }
120 highlighted_html.push_str("</code></pre>");
121 new_events.push(Event::Html(CowStr::from(highlighted_html)));
122 }
123
124 in_code_block = false;
126 code_buffer.clear();
127 code_lang.clear();
128 } else {
129 new_events.push(Event::End(TagEnd::CodeBlock));
130 }
131 }
132 Event::Text(text) if !in_code_block => {
133 let processed_text = self.replace_emoji_shortcodes(&text);
135 new_events.push(Event::Text(CowStr::from(processed_text)));
136 }
137 e => {
138 if !in_code_block {
139 new_events.push(e);
140 }
141 }
142 }
143 }
144
145 let mut html_output = String::new();
146 html::push_html(&mut html_output, new_events.into_iter());
147
148 let html_output = self.process_github_alerts(&html_output);
150
151 let (html_output, toc) = self.add_heading_ids_and_extract_toc(&html_output);
153
154 let fence_warnings = Self::detect_fence_issues(markdown);
156 let warnings_html = Self::build_fence_warnings_html(&fence_warnings);
157 let html_output = if warnings_html.is_empty() {
158 html_output
159 } else {
160 format!("{warnings_html}{html_output}")
161 };
162
163 (html_output, has_mermaid, toc)
164 }
165
166 fn process_github_alerts(&self, html: &str) -> String {
167 ALERT_REGEX
168 .replace_all(html, |caps: ®ex::Captures| {
169 if let (Some(alert_type), Some(first_line), Some(remaining)) =
170 (caps.get(1), caps.get(2), caps.get(3))
171 {
172 let alert_type = alert_type.as_str();
173 let first_line = first_line.as_str();
174 let remaining = remaining.as_str();
175
176 let content = if remaining.trim().is_empty() {
178 first_line.to_string()
179 } else {
180 format!("{first_line}{remaining}")
181 };
182
183 self.generate_alert_html(alert_type, &content)
185 } else {
186 caps[0].to_string()
187 }
188 })
189 .to_string()
190 }
191
192 fn generate_alert_html(&self, alert_type: &str, content: &str) -> String {
193 let (icon_svg, title) = match alert_type {
194 "NOTE" => (
195 r#"<svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>"#,
196 "Note",
197 ),
198 "TIP" => (
199 r#"<svg class="octicon octicon-light-bulb mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>"#,
200 "Tip",
201 ),
202 "IMPORTANT" => (
203 r#"<svg class="octicon octicon-report mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>"#,
204 "Important",
205 ),
206 "WARNING" => (
207 r#"<svg class="octicon octicon-alert mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>"#,
208 "Warning",
209 ),
210 "CAUTION" => (
211 r#"<svg class="octicon octicon-stop mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>"#,
212 "Caution",
213 ),
214 _ => ("", "Note"),
215 };
216
217 let alert_class = alert_type.to_lowercase();
218
219 format!(
220 r#"<div class="markdown-alert markdown-alert-{alert_class}">
221<p class="markdown-alert-title">
222{icon_svg}{title}
223</p>
224{content}
225</div>"#
226 )
227 }
228
229 fn replace_emoji_shortcodes(&self, text: &str) -> String {
230 EMOJI_REGEX
231 .replace_all(text, |caps: ®ex::Captures| {
232 let shortcode = &caps[1];
233
234 if let Some(emoji) = emojis::get_by_shortcode(shortcode) {
236 emoji.as_str().to_string()
237 } else {
238 caps[0].to_string()
240 }
241 })
242 .to_string()
243 }
244
245 fn add_heading_ids_and_extract_toc(&self, html: &str) -> (String, Vec<TocItem>) {
246 let mut toc = Vec::new();
247 let mut headings = Vec::new();
248 let mut id_counts: std::collections::HashMap<String, u32> =
249 std::collections::HashMap::new();
250
251 for caps in HEADING_REGEX.captures_iter(html) {
253 if let (Some(tag), Some(content), Some(m)) = (caps.get(1), caps.get(2), caps.get(0)) {
254 let tag = tag.as_str();
255 let content = content.as_str();
256 let level = tag.chars().nth(1).and_then(|c| c.to_digit(10)).unwrap_or(1) as u8;
257 let base_id = self.generate_slug(content);
258 let count = id_counts.entry(base_id.clone()).or_insert(0);
260 let id = if *count == 0 {
261 base_id.clone()
262 } else {
263 format!("{}-{}", base_id, count)
264 };
265 *id_counts.get_mut(&base_id).unwrap() += 1;
266 let text = HTML_TAG_REGEX.replace_all(content, "").to_string();
267
268 toc.push(TocItem {
269 level,
270 id: id.clone(),
271 text,
272 });
273
274 headings.push((
275 m.start(),
276 m.end(),
277 level,
278 tag.to_string(),
279 id,
280 content.to_string(),
281 ));
282 }
283 }
284
285 let mut result = String::new();
287 let mut last_pos = 0;
288 let mut open_sections: Vec<u8> = Vec::new();
289
290 for (start, end, level, tag, id, content) in &headings {
291 result.push_str(&html[last_pos..*start]);
293
294 while let Some(&last_level) = open_sections.last() {
296 if last_level >= *level {
297 result.push_str("</div>");
298 open_sections.pop();
299 } else {
300 break;
301 }
302 }
303
304 result.push_str(&format!(
306 "<div class=\"heading-section\" data-level=\"{level}\">"
307 ));
308 open_sections.push(*level);
309
310 result.push_str(&format!("<{tag} id=\"{id}\">{content}</{tag}>"));
312
313 last_pos = *end;
314 }
315
316 result.push_str(&html[last_pos..]);
318
319 for _ in open_sections {
321 result.push_str("</div>");
322 }
323
324 (result, toc)
325 }
326
327 fn detect_fence_issues(markdown: &str) -> Vec<FenceWarning> {
328 let mut warnings = Vec::new();
329 let lines: Vec<&str> = markdown.lines().collect();
330 let mut i = 0;
331
332 while i < lines.len() {
333 let trimmed = lines[i].trim_start();
334 let (ch, count) = Self::count_fence_chars(trimmed);
335
336 if count >= 3 {
337 let has_info = !trimmed[ch.len_utf8() * count..].trim().is_empty();
338 if has_info {
339 let outer_start = i + 1;
340 let outer_count = count;
341 let outer_char = ch;
342 let mut saw_inner_open = false;
343 i += 1;
344
345 while i < lines.len() {
346 let inner = lines[i].trim_start();
347 let (ic, icount) = Self::count_fence_chars(inner);
348
349 if ic == outer_char && icount >= outer_count {
350 let inner_has_info = !inner[ic.len_utf8() * icount..].trim().is_empty();
351 if inner_has_info {
352 saw_inner_open = true;
353 } else if saw_inner_open {
354 let mut j = i + 1;
357 while j < lines.len() && lines[j].trim().is_empty() {
358 j += 1;
359 }
360 if j < lines.len() {
361 let next = lines[j].trim_start();
362 if next.starts_with('#') {
363 warnings.push(FenceWarning {
364 line: i + 1,
365 outer_start,
366 backtick_count: outer_count,
367 });
368 }
369 }
370 break;
371 } else {
372 break;
373 }
374 }
375 i += 1;
376 }
377 }
378 }
379 i += 1;
380 }
381
382 warnings
383 }
384
385 fn count_fence_chars(line: &str) -> (char, usize) {
386 let first = match line.chars().next() {
387 Some(c @ '`') | Some(c @ '~') => c,
388 _ => return (' ', 0),
389 };
390 let count = line.chars().take_while(|&c| c == first).count();
391 (first, count)
392 }
393
394 fn build_fence_warnings_html(warnings: &[FenceWarning]) -> String {
395 if warnings.is_empty() {
396 return String::new();
397 }
398 let mut html = String::new();
399 for w in warnings {
400 html.push_str(&format!(
401 r#"<div class="markdown-alert markdown-alert-warning">
402<p class="markdown-alert-title">
403<svg class="octicon octicon-alert mr-2" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>Markdown Warning
404</p>
405<p>Line {line}: code fence closed prematurely — the code block starting at line {outer} uses {count} backticks, but an inner fence with the same count closes it early. Use {fix} backticks for the outer fence to fix this. <a href="javascript:void(0)" onclick="openEditorAtLine({line})" style="text-decoration:underline;cursor:pointer">Edit line {line}</a></p>
406</div>"#,
407 line = w.line,
408 outer = w.outer_start,
409 count = w.backtick_count,
410 fix = w.backtick_count + 1,
411 ));
412 }
413 html
414 }
415
416 fn generate_slug(&self, text: &str) -> String {
417 let text = HTML_TAG_REGEX.replace_all(text, "");
419
420 let slug = text
422 .trim()
423 .to_lowercase()
424 .chars()
425 .map(|c| {
426 if c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '_' {
427 c
428 } else {
429 '-'
430 }
431 })
432 .collect::<String>()
433 .split_whitespace()
434 .collect::<Vec<_>>()
435 .join("-");
436
437 MULTI_HYPHEN_REGEX.replace_all(&slug, "-").to_string()
439 }
440}