Skip to main content

markon_core/
markdown.rs

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                        // Check if this is a Mermaid diagram
88                        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                            // Regular code block with syntax highlighting
97                            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                        // Reset state
125                        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                    // Replace emoji shortcodes
134                    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        // Process GitHub Alerts
149        let html_output = self.process_github_alerts(&html_output);
150
151        // Add heading IDs and extract table of contents
152        let (html_output, toc) = self.add_heading_ids_and_extract_toc(&html_output);
153
154        // Validate code fences and prepend warnings
155        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: &regex::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                    // Combine content
177                    let content = if remaining.trim().is_empty() {
178                        first_line.to_string()
179                    } else {
180                        format!("{first_line}{remaining}")
181                    };
182
183                    // Generate corresponding alert HTML
184                    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: &regex::Captures| {
232                let shortcode = &caps[1];
233
234                // Look up emoji using emojis crate
235                if let Some(emoji) = emojis::get_by_shortcode(shortcode) {
236                    emoji.as_str().to_string()
237                } else {
238                    // If not found, keep original text
239                    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        // First pass: collect all headings with their positions
252        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                // Deduplicate: append -1, -2, etc. for repeated headings
259                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        // Second pass: build new HTML with section containers
286        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            // Add content before this heading
292            result.push_str(&html[last_pos..*start]);
293
294            // Close sections that are same or higher level
295            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            // Open new section
305            result.push_str(&format!(
306                "<div class=\"heading-section\" data-level=\"{level}\">"
307            ));
308            open_sections.push(*level);
309
310            // Add the heading with ID
311            result.push_str(&format!("<{tag} id=\"{id}\">{content}</{tag}>"));
312
313            last_pos = *end;
314        }
315
316        // Add remaining content
317        result.push_str(&html[last_pos..]);
318
319        // Close all remaining sections
320        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                                // This closing fence matches the outer block.
355                                // Check if content continues after (suggesting premature close).
356                                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        // Remove HTML tags
418        let text = HTML_TAG_REGEX.replace_all(text, "");
419
420        // Convert to lowercase and replace spaces/special chars with hyphens
421        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        // Remove consecutive hyphens
438        MULTI_HYPHEN_REGEX.replace_all(&slug, "-").to_string()
439    }
440}