use once_cell::sync::Lazy;
use regex::Regex;
static MERMAID_BLOCK_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?s)<pre><code class="language-mermaid">(.*?)</code></pre>"#,
)
.expect("static MERMAID_BLOCK_REGEX must compile")
});
#[must_use]
pub fn rewrite_mermaid_blocks(html: &str) -> String {
if !html.contains("language-mermaid") {
return html.to_string();
}
MERMAID_BLOCK_REGEX
.replace_all(html, r#"<pre class="mermaid">$1</pre>"#)
.into_owned()
}
#[cfg(feature = "math")]
static DISPLAY_MATH_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?s)\$\$([^$].*?)\$\$")
.expect("static DISPLAY_MATH_REGEX must compile")
});
#[cfg(feature = "math")]
static INLINE_MATH_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\$([^\s$][^$]*?[^\s\\$])\$(?:[^0-9]|$)")
.expect("static INLINE_MATH_REGEX must compile")
});
#[cfg(feature = "math")]
#[must_use]
pub fn convert_math(html: &str) -> String {
if !html.contains('$') {
return html.to_string();
}
let mut out = String::with_capacity(html.len());
let mut last = 0usize;
for m in DISPLAY_MATH_REGEX.captures_iter(html) {
let mat = m.get(0).expect("regex match has group 0");
let latex = m
.get(1)
.expect("DISPLAY_MATH_REGEX has capture group 1")
.as_str();
out.push_str(&html[last..mat.start()]);
out.push_str(&render_latex(latex, true));
last = mat.end();
}
out.push_str(&html[last..]);
if !out.contains('$') {
return out;
}
let pass1 = out;
let mut out = String::with_capacity(pass1.len());
let mut last = 0usize;
for m in INLINE_MATH_REGEX.captures_iter(&pass1) {
let mat = m.get(0).expect("regex match has group 0");
let latex = m
.get(1)
.expect("INLINE_MATH_REGEX has capture group 1")
.as_str();
let tail = mat.as_str();
let trailer = match tail.chars().last() {
Some('$') => "",
Some(_) => &tail[tail.len() - 1..],
None => "",
};
out.push_str(&pass1[last..mat.start()]);
out.push_str(&render_latex(latex, false));
out.push_str(trailer);
last = mat.end();
}
out.push_str(&pass1[last..]);
out
}
#[cfg(feature = "math")]
fn render_latex(src: &str, display: bool) -> String {
use pulldown_latex::config::{DisplayMode, RenderConfig};
use pulldown_latex::{mathml::push_mathml, Parser, Storage};
let storage = Storage::new();
let parser = Parser::new(src, &storage);
let mut out = String::new();
let cfg = RenderConfig {
display_mode: if display {
DisplayMode::Block
} else {
DisplayMode::Inline
},
..Default::default()
};
let _ = push_mathml(&mut out, parser, cfg);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mermaid_fast_path_is_pass_through() {
let html = "<p>no diagrams here</p>";
assert_eq!(rewrite_mermaid_blocks(html), html);
}
#[test]
fn mermaid_block_is_rewritten() {
let input = r#"<pre><code class="language-mermaid">graph TD;A-->B</code></pre>"#;
let out = rewrite_mermaid_blocks(input);
assert_eq!(
out,
r#"<pre class="mermaid">graph TD;A-->B</pre>"#
);
}
#[test]
fn mermaid_multiple_blocks_each_rewritten() {
let input = r#"<pre><code class="language-mermaid">a-->b</code></pre>between<pre><code class="language-mermaid">c-->d</code></pre>"#;
let out = rewrite_mermaid_blocks(input);
assert_eq!(
out,
r#"<pre class="mermaid">a-->b</pre>between<pre class="mermaid">c-->d</pre>"#,
);
assert_eq!(out.matches(r#"<pre class="mermaid">"#).count(), 2);
assert!(!out.contains("language-mermaid"));
}
#[cfg(feature = "math")]
#[test]
fn math_fast_path_no_dollar_passes_through() {
let html = "<p>no math here</p>";
assert_eq!(convert_math(html), html);
}
#[cfg(feature = "math")]
#[test]
fn math_inline_is_rendered() {
let out = convert_math("<p>$x+y$</p>");
assert!(out.contains("<math"));
assert!(out.contains("</math>"));
assert!(!out.contains(r#"display="block""#));
}
#[cfg(feature = "math")]
#[test]
fn math_display_uses_block_attribute() {
let out = convert_math("<p>$$E=mc^2$$</p>");
assert!(out.contains("<math"));
assert!(out.contains(r#"display="block""#));
}
#[cfg(feature = "math")]
#[test]
fn math_dollar_followed_by_digit_left_alone() {
let out = convert_math("<p>That cost $5 yesterday.</p>");
assert_eq!(out, "<p>That cost $5 yesterday.</p>");
}
#[cfg(feature = "math")]
#[test]
fn math_unbalanced_dollar_left_alone() {
let out = convert_math("<p>only one $ here</p>");
assert_eq!(out, "<p>only one $ here</p>");
}
#[cfg(feature = "math")]
#[test]
fn math_invalid_latex_emits_inline_merror_marker() {
let out = convert_math("<p>$a_b_c$</p>");
assert!(
out.contains("<merror"),
"expected inline <merror> marker, got: {out}"
);
}
#[cfg(feature = "math")]
#[test]
fn math_block_and_inline_in_same_input() {
let out = convert_math("<p>see $a+b$ and $$c+d$$.</p>");
assert_eq!(out.matches("<math").count(), 2);
assert!(out.contains(r#"display="block""#));
}
}