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 let theme_name = match self.theme.as_str() {
55 "light" => "Solarized (light)",
56 "dark" => "base16-ocean.dark",
57 _ => "base16-ocean.dark", };
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 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 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 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 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 let html_output = self.process_github_alerts(&html_output);
142
143 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: ®ex::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 let content = if remaining.trim().is_empty() {
161 first_line.to_string()
162 } else {
163 format!("{first_line}{remaining}")
164 };
165
166 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: ®ex::Captures| {
215 let shortcode = &caps[1];
216
217 if let Some(emoji) = emojis::get_by_shortcode(shortcode) {
219 emoji.as_str().to_string()
220 } else {
221 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 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 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 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 result.push_str(&html[last_pos..*start]);
276
277 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 result.push_str(&format!(
289 "<div class=\"heading-section\" data-level=\"{level}\">"
290 ));
291 open_sections.push(*level);
292
293 result.push_str(&format!("<{tag} id=\"{id}\">{content}</{tag}>"));
295
296 last_pos = *end;
297 }
298
299 result.push_str(&html[last_pos..]);
301
302 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 let text = HTML_TAG_REGEX.replace_all(text, "");
313
314 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 MULTI_HYPHEN_REGEX.replace_all(&slug, "-").to_string()
333 }
334}