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
10lazy_static! {
11    static ref EMOJI_REGEX: Regex = Regex::new(r":([a-zA-Z0-9_+-]+):")
12        .expect("Failed to compile EMOJI_REGEX");
13    static ref ALERT_REGEX: Regex = Regex::new(
14        r"(?s)<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*?)</p>(.*?)</blockquote>"
15    ).expect("Failed to compile ALERT_REGEX");
16    static ref HEADING_REGEX: Regex = Regex::new(r"<(h[1-6])>(.*?)</h[1-6]>")
17        .expect("Failed to compile HEADING_REGEX");
18    static ref HTML_TAG_REGEX: Regex = Regex::new(r"<[^>]+>")
19        .expect("Failed to compile HTML_TAG_REGEX");
20    static ref MULTI_HYPHEN_REGEX: Regex = Regex::new(r"-+")
21        .expect("Failed to compile MULTI_HYPHEN_REGEX");
22}
23
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct TocItem {
26    pub level: u8,
27    pub id: String,
28    pub text: String,
29}
30
31pub struct MarkdownRenderer {
32    theme: String,
33}
34
35impl MarkdownRenderer {
36    pub fn new(theme: &str) -> Self {
37        Self {
38            theme: theme.to_string(),
39        }
40    }
41
42    pub fn render(&self, markdown: &str) -> (String, bool, Vec<TocItem>) {
43        let mut options = Options::empty();
44        options.insert(Options::ENABLE_TABLES);
45        options.insert(Options::ENABLE_FOOTNOTES);
46        options.insert(Options::ENABLE_STRIKETHROUGH);
47        options.insert(Options::ENABLE_TASKLISTS);
48        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
49
50        let ss = SyntaxSet::load_defaults_newlines();
51        let ts = ThemeSet::load_defaults();
52
53        // Select syntax highlighting theme based on user preference
54        let theme_name = match self.theme.as_str() {
55            "light" => "Solarized (light)",
56            "dark" => "base16-ocean.dark",
57            _ => "base16-ocean.dark", // auto defaults to dark
58        };
59        let theme = &ts.themes[theme_name];
60
61        let parser = Parser::new_ext(markdown, options);
62        let mut new_events = Vec::new();
63        let mut in_code_block = false;
64        let mut code_lang = String::new();
65        let mut code_buffer = String::new();
66        let mut has_mermaid = false;
67
68        for event in parser {
69            match event {
70                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(fence_lang))) => {
71                    in_code_block = true;
72                    code_lang = fence_lang.to_string();
73                }
74                Event::Text(text) if in_code_block => {
75                    code_buffer.push_str(&text);
76                }
77                Event::End(TagEnd::CodeBlock) => {
78                    if in_code_block {
79                        // Check if this is a Mermaid diagram
80                        if code_lang.to_lowercase() == "mermaid" {
81                            has_mermaid = true;
82                            let mermaid_html = format!(
83                                "<pre class=\"mermaid\">{}</pre>",
84                                html_escape::encode_text(&code_buffer)
85                            );
86                            new_events.push(Event::Html(CowStr::from(mermaid_html)));
87                        } else {
88                            // Regular code block with syntax highlighting
89                            let syntax = ss
90                                .find_syntax_by_extension(&code_lang)
91                                .unwrap_or_else(|| ss.find_syntax_plain_text());
92                            let mut highlighter = HighlightLines::new(syntax, theme);
93
94                            let mut highlighted_html = String::from("<pre><code>");
95                            for line in LinesWithEndings::from(&code_buffer) {
96                                match highlighter.highlight_line(line, &ss) {
97                                    Ok(ranges) => {
98                                        match styled_line_to_highlighted_html(
99                                            &ranges[..],
100                                            IncludeBackground::No,
101                                        ) {
102                                            Ok(escaped) => highlighted_html.push_str(&escaped),
103                                            Err(_) => highlighted_html
104                                                .push_str(&html_escape::encode_text(line)),
105                                        }
106                                    }
107                                    Err(_) => {
108                                        highlighted_html.push_str(&html_escape::encode_text(line))
109                                    }
110                                }
111                            }
112                            highlighted_html.push_str("</code></pre>");
113                            new_events.push(Event::Html(CowStr::from(highlighted_html)));
114                        }
115
116                        // Reset state
117                        in_code_block = false;
118                        code_buffer.clear();
119                        code_lang.clear();
120                    } else {
121                        new_events.push(Event::End(TagEnd::CodeBlock));
122                    }
123                }
124                Event::Text(text) if !in_code_block => {
125                    // Replace emoji shortcodes
126                    let processed_text = self.replace_emoji_shortcodes(&text);
127                    new_events.push(Event::Text(CowStr::from(processed_text)));
128                }
129                e => {
130                    if !in_code_block {
131                        new_events.push(e);
132                    }
133                }
134            }
135        }
136
137        let mut html_output = String::new();
138        html::push_html(&mut html_output, new_events.into_iter());
139
140        // Process GitHub Alerts
141        let html_output = self.process_github_alerts(&html_output);
142
143        // Add heading IDs and extract table of contents
144        let (html_output, toc) = self.add_heading_ids_and_extract_toc(&html_output);
145
146        (html_output, has_mermaid, toc)
147    }
148
149    fn process_github_alerts(&self, html: &str) -> String {
150        ALERT_REGEX
151            .replace_all(html, |caps: &regex::Captures| {
152                if let (Some(alert_type), Some(first_line), Some(remaining)) =
153                    (caps.get(1), caps.get(2), caps.get(3))
154                {
155                    let alert_type = alert_type.as_str();
156                    let first_line = first_line.as_str();
157                    let remaining = remaining.as_str();
158
159                    // Combine content
160                    let content = if remaining.trim().is_empty() {
161                        first_line.to_string()
162                    } else {
163                        format!("{first_line}{remaining}")
164                    };
165
166                    // Generate corresponding alert HTML
167                    self.generate_alert_html(alert_type, &content)
168                } else {
169                    caps[0].to_string()
170                }
171            })
172            .to_string()
173    }
174
175    fn generate_alert_html(&self, alert_type: &str, content: &str) -> String {
176        let (icon_svg, title) = match alert_type {
177            "NOTE" => (
178                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>"#,
179                "Note",
180            ),
181            "TIP" => (
182                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>"#,
183                "Tip",
184            ),
185            "IMPORTANT" => (
186                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>"#,
187                "Important",
188            ),
189            "WARNING" => (
190                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>"#,
191                "Warning",
192            ),
193            "CAUTION" => (
194                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>"#,
195                "Caution",
196            ),
197            _ => ("", "Note"),
198        };
199
200        let alert_class = alert_type.to_lowercase();
201
202        format!(
203            r#"<div class="markdown-alert markdown-alert-{alert_class}">
204<p class="markdown-alert-title">
205{icon_svg}{title}
206</p>
207{content}
208</div>"#
209        )
210    }
211
212    fn replace_emoji_shortcodes(&self, text: &str) -> String {
213        EMOJI_REGEX
214            .replace_all(text, |caps: &regex::Captures| {
215                let shortcode = &caps[1];
216
217                // Look up emoji using emojis crate
218                if let Some(emoji) = emojis::get_by_shortcode(shortcode) {
219                    emoji.as_str().to_string()
220                } else {
221                    // If not found, keep original text
222                    caps[0].to_string()
223                }
224            })
225            .to_string()
226    }
227
228    fn add_heading_ids_and_extract_toc(&self, html: &str) -> (String, Vec<TocItem>) {
229        let mut toc = Vec::new();
230        let mut headings = Vec::new();
231        let mut id_counts: std::collections::HashMap<String, u32> =
232            std::collections::HashMap::new();
233
234        // First pass: collect all headings with their positions
235        for caps in HEADING_REGEX.captures_iter(html) {
236            if let (Some(tag), Some(content), Some(m)) = (caps.get(1), caps.get(2), caps.get(0)) {
237                let tag = tag.as_str();
238                let content = content.as_str();
239                let level = tag.chars().nth(1).and_then(|c| c.to_digit(10)).unwrap_or(1) as u8;
240                let base_id = self.generate_slug(content);
241                // Deduplicate: append -1, -2, etc. for repeated headings
242                let count = id_counts.entry(base_id.clone()).or_insert(0);
243                let id = if *count == 0 {
244                    base_id.clone()
245                } else {
246                    format!("{}-{}", base_id, count)
247                };
248                *id_counts.get_mut(&base_id).unwrap() += 1;
249                let text = HTML_TAG_REGEX.replace_all(content, "").to_string();
250
251                toc.push(TocItem {
252                    level,
253                    id: id.clone(),
254                    text,
255                });
256
257                headings.push((
258                    m.start(),
259                    m.end(),
260                    level,
261                    tag.to_string(),
262                    id,
263                    content.to_string(),
264                ));
265            }
266        }
267
268        // Second pass: build new HTML with section containers
269        let mut result = String::new();
270        let mut last_pos = 0;
271        let mut open_sections: Vec<u8> = Vec::new();
272
273        for (start, end, level, tag, id, content) in &headings {
274            // Add content before this heading
275            result.push_str(&html[last_pos..*start]);
276
277            // Close sections that are same or higher level
278            while let Some(&last_level) = open_sections.last() {
279                if last_level >= *level {
280                    result.push_str("</div>");
281                    open_sections.pop();
282                } else {
283                    break;
284                }
285            }
286
287            // Open new section
288            result.push_str(&format!(
289                "<div class=\"heading-section\" data-level=\"{level}\">"
290            ));
291            open_sections.push(*level);
292
293            // Add the heading with ID
294            result.push_str(&format!("<{tag} id=\"{id}\">{content}</{tag}>"));
295
296            last_pos = *end;
297        }
298
299        // Add remaining content
300        result.push_str(&html[last_pos..]);
301
302        // Close all remaining sections
303        for _ in open_sections {
304            result.push_str("</div>");
305        }
306
307        (result, toc)
308    }
309
310    fn generate_slug(&self, text: &str) -> String {
311        // Remove HTML tags
312        let text = HTML_TAG_REGEX.replace_all(text, "");
313
314        // Convert to lowercase and replace spaces/special chars with hyphens
315        let slug = text
316            .trim()
317            .to_lowercase()
318            .chars()
319            .map(|c| {
320                if c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '_' {
321                    c
322                } else {
323                    '-'
324                }
325            })
326            .collect::<String>()
327            .split_whitespace()
328            .collect::<Vec<_>>()
329            .join("-");
330
331        // Remove consecutive hyphens
332        MULTI_HYPHEN_REGEX.replace_all(&slug, "-").to_string()
333    }
334}