use lazy_static::lazy_static;
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd};
use regex::Regex;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
#[derive(Debug)]
struct FenceWarning {
line: usize,
outer_start: usize,
backtick_count: usize,
}
lazy_static! {
static ref EMOJI_REGEX: Regex = Regex::new(r":([a-zA-Z0-9_+-]+):")
.expect("Failed to compile EMOJI_REGEX");
static ref ALERT_REGEX: Regex = Regex::new(
r"(?s)<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*?)</p>(.*?)</blockquote>"
).expect("Failed to compile ALERT_REGEX");
static ref HEADING_REGEX: Regex = Regex::new(r"<(h[1-6])>(.*?)</h[1-6]>")
.expect("Failed to compile HEADING_REGEX");
static ref HTML_TAG_REGEX: Regex = Regex::new(r"<[^>]+>")
.expect("Failed to compile HTML_TAG_REGEX");
static ref MULTI_HYPHEN_REGEX: Regex = Regex::new(r"-+")
.expect("Failed to compile MULTI_HYPHEN_REGEX");
static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines();
static ref THEME_SET: ThemeSet = ThemeSet::load_defaults();
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TocItem {
pub level: u8,
pub id: String,
pub text: String,
}
pub struct MarkdownRenderer {
theme: String,
}
impl MarkdownRenderer {
pub fn new(theme: &str) -> Self {
Self {
theme: theme.to_string(),
}
}
pub fn render(&self, markdown: &str) -> (String, bool, Vec<TocItem>) {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let ss: &SyntaxSet = &SYNTAX_SET;
let ts: &ThemeSet = &THEME_SET;
let theme_name = match self.theme.as_str() {
"light" => "Solarized (light)",
"dark" => "base16-ocean.dark",
_ => "base16-ocean.dark",
};
let theme = &ts.themes[theme_name];
let parser = Parser::new_ext(markdown, options);
let mut new_events = Vec::new();
let mut in_code_block = false;
let mut code_lang = String::new();
let mut code_buffer = String::new();
let mut has_mermaid = false;
for event in parser {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(fence_lang))) => {
in_code_block = true;
code_lang = fence_lang.to_string();
}
Event::Text(text) if in_code_block => {
code_buffer.push_str(&text);
}
Event::End(TagEnd::CodeBlock) => {
if in_code_block {
// Check if this is a Mermaid diagram
if code_lang.to_lowercase() == "mermaid" {
has_mermaid = true;
let mermaid_html = format!(
"<pre class=\"mermaid\">{}</pre>",
html_escape::encode_text(&code_buffer)
);
new_events.push(Event::Html(CowStr::from(mermaid_html)));
} else {
// Regular code block with syntax highlighting
let syntax = ss
.find_syntax_by_extension(&code_lang)
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, theme);
let mut highlighted_html = String::from("<pre><code>");
for line in LinesWithEndings::from(&code_buffer) {
match highlighter.highlight_line(line, ss) {
Ok(ranges) => {
match styled_line_to_highlighted_html(
&ranges[..],
IncludeBackground::No,
) {
Ok(escaped) => highlighted_html.push_str(&escaped),
Err(_) => highlighted_html
.push_str(&html_escape::encode_text(line)),
}
}
Err(_) => {
highlighted_html.push_str(&html_escape::encode_text(line))
}
}
}
highlighted_html.push_str("</code></pre>");
new_events.push(Event::Html(CowStr::from(highlighted_html)));
}
// Reset state
in_code_block = false;
code_buffer.clear();
code_lang.clear();
} else {
new_events.push(Event::End(TagEnd::CodeBlock));
}
}
Event::Text(text) if !in_code_block => {
// Replace emoji shortcodes
let processed_text = self.replace_emoji_shortcodes(&text);
new_events.push(Event::Text(CowStr::from(processed_text)));
}
e => {
if !in_code_block {
new_events.push(e);
}
}
}
}
let mut html_output = String::new();
html::push_html(&mut html_output, new_events.into_iter());
// Process GitHub Alerts
let html_output = self.process_github_alerts(&html_output);
// Add heading IDs and extract table of contents
let (html_output, toc) = self.add_heading_ids_and_extract_toc(&html_output);
// Validate code fences and prepend warnings
let fence_warnings = Self::detect_fence_issues(markdown);
let warnings_html = Self::build_fence_warnings_html(&fence_warnings);
let html_output = if warnings_html.is_empty() {
html_output
} else {
format!("{warnings_html}{html_output}")
};
(html_output, has_mermaid, toc)
}
fn process_github_alerts(&self, html: &str) -> String {
ALERT_REGEX
.replace_all(html, |caps: ®ex::Captures| {
if let (Some(alert_type), Some(first_line), Some(remaining)) =
(caps.get(1), caps.get(2), caps.get(3))
{
let alert_type = alert_type.as_str();
let first_line = first_line.as_str();
let remaining = remaining.as_str();
// Combine content
let content = if remaining.trim().is_empty() {
first_line.to_string()
} else {
format!("{first_line}{remaining}")
};
// Generate corresponding alert HTML
self.generate_alert_html(alert_type, &content)
} else {
caps[0].to_string()
}
})
.to_string()
}
fn generate_alert_html(&self, alert_type: &str, content: &str) -> String {
let (icon_svg, title) = match alert_type {
"NOTE" => (
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>"#,
"Note",
),
"TIP" => (
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>"#,
"Tip",
),
"IMPORTANT" => (
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>"#,
"Important",
),
"WARNING" => (
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>"#,
"Warning",
),
"CAUTION" => (
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>"#,
"Caution",
),
_ => ("", "Note"),
};
let alert_class = alert_type.to_lowercase();
format!(
r#"<div class="markdown-alert markdown-alert-{alert_class}">
<p class="markdown-alert-title">
{icon_svg}{title}
</p>
{content}
</div>"#
)
}
fn replace_emoji_shortcodes(&self, text: &str) -> String {
EMOJI_REGEX
.replace_all(text, |caps: ®ex::Captures| {
let shortcode = &caps[1];
// Look up emoji using emojis crate
if let Some(emoji) = emojis::get_by_shortcode(shortcode) {
emoji.as_str().to_string()
} else {
// If not found, keep original text
caps[0].to_string()
}
})
.to_string()
}
fn add_heading_ids_and_extract_toc(&self, html: &str) -> (String, Vec<TocItem>) {
let mut toc = Vec::new();
let mut headings = Vec::new();
let mut id_counts: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
// First pass: collect all headings with their positions
for caps in HEADING_REGEX.captures_iter(html) {
if let (Some(tag), Some(content), Some(m)) = (caps.get(1), caps.get(2), caps.get(0)) {
let tag = tag.as_str();
let content = content.as_str();
let level = tag.chars().nth(1).and_then(|c| c.to_digit(10)).unwrap_or(1) as u8;
let base_id = self.generate_slug(content);
// Deduplicate: append -1, -2, etc. for repeated headings
let count = id_counts.entry(base_id.clone()).or_insert(0);
let id = if *count == 0 {
base_id.clone()
} else {
format!("{}-{}", base_id, count)
};
*id_counts.get_mut(&base_id).unwrap() += 1;
let text = HTML_TAG_REGEX.replace_all(content, "").to_string();
toc.push(TocItem {
level,
id: id.clone(),
text,
});
headings.push((
m.start(),
m.end(),
level,
tag.to_string(),
id,
content.to_string(),
));
}
}
// Second pass: build new HTML with section containers
let mut result = String::new();
let mut last_pos = 0;
let mut open_sections: Vec<u8> = Vec::new();
for (start, end, level, tag, id, content) in &headings {
// Add content before this heading
result.push_str(&html[last_pos..*start]);
// Close sections that are same or higher level
while let Some(&last_level) = open_sections.last() {
if last_level >= *level {
result.push_str("</div>");
open_sections.pop();
} else {
break;
}
}
// Open new section
result.push_str(&format!(
"<div class=\"heading-section\" data-level=\"{level}\">"
));
open_sections.push(*level);
// Add the heading with ID
result.push_str(&format!("<{tag} id=\"{id}\">{content}</{tag}>"));
last_pos = *end;
}
// Add remaining content
result.push_str(&html[last_pos..]);
// Close all remaining sections
for _ in open_sections {
result.push_str("</div>");
}
(result, toc)
}
fn detect_fence_issues(markdown: &str) -> Vec<FenceWarning> {
let mut warnings = Vec::new();
let lines: Vec<&str> = markdown.lines().collect();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim_start();
let (ch, count) = Self::count_fence_chars(trimmed);
if count >= 3 {
let has_info = !trimmed[ch.len_utf8() * count..].trim().is_empty();
if has_info {
let outer_start = i + 1;
let outer_count = count;
let outer_char = ch;
let mut saw_inner_open = false;
i += 1;
while i < lines.len() {
let inner = lines[i].trim_start();
let (ic, icount) = Self::count_fence_chars(inner);
if ic == outer_char && icount >= outer_count {
let inner_has_info = !inner[ic.len_utf8() * icount..].trim().is_empty();
if inner_has_info {
saw_inner_open = true;
} else if saw_inner_open {
// This closing fence matches the outer block.
// Check if content continues after (suggesting premature close).
let mut j = i + 1;
while j < lines.len() && lines[j].trim().is_empty() {
j += 1;
}
if j < lines.len() {
let next = lines[j].trim_start();
if next.starts_with('#') {
warnings.push(FenceWarning {
line: i + 1,
outer_start,
backtick_count: outer_count,
});
}
}
break;
} else {
break;
}
}
i += 1;
}
}
}
i += 1;
}
warnings
}
fn count_fence_chars(line: &str) -> (char, usize) {
let first = match line.chars().next() {
Some(c @ '`') | Some(c @ '~') => c,
_ => return (' ', 0),
};
let count = line.chars().take_while(|&c| c == first).count();
(first, count)
}
fn build_fence_warnings_html(warnings: &[FenceWarning]) -> String {
if warnings.is_empty() {
return String::new();
}
let mut html = String::new();
for w in warnings {
html.push_str(&format!(
r#"<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title">
<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
</p>
<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>
</div>"#,
line = w.line,
outer = w.outer_start,
count = w.backtick_count,
fix = w.backtick_count + 1,
));
}
html
}
fn generate_slug(&self, text: &str) -> String {
// Remove HTML tags
let text = HTML_TAG_REGEX.replace_all(text, "");
// Convert to lowercase and replace spaces/special chars with hyphens
let slug = text
.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-");
// Remove consecutive hyphens
MULTI_HYPHEN_REGEX.replace_all(&slug, "-").to_string()
}
}