use bytes::Bytes;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
const KEY_PREFIX: &str = "mermaid/diagram-";
#[derive(Debug, Clone)]
pub struct RenderedDiagrams {
pub markdown: String,
pub svgs: Vec<(String, Bytes)>,
}
struct Fence {
range: std::ops::Range<usize>,
source: String,
}
pub fn render_diagrams(markdown: &str) -> RenderedDiagrams {
let mut fences: Vec<Fence> = Vec::new();
let mut current: Option<Fence> = None;
for (event, range) in Parser::new_ext(markdown, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) if is_mermaid(&info) => {
current = Some(Fence {
range,
source: String::new(),
});
}
Event::Text(text) => {
if let Some(f) = current.as_mut() {
f.source.push_str(&text);
}
}
Event::End(TagEnd::CodeBlock) => {
if let Some(f) = current.take() {
fences.push(f);
}
}
_ => {}
}
}
let mut svgs: Vec<(String, Bytes)> = Vec::new();
let mut out = markdown.to_string();
let total = fences.len();
for (i, f) in fences.iter().enumerate().rev() {
match mermaid_svg::render(&f.source) {
Ok(svg) => {
let key = format!("{KEY_PREFIX}{i}.svg");
let trailing_nl = markdown[f.range.clone()].ends_with('\n');
let replacement = format!("{}", if trailing_nl { "\n" } else { "" });
out.replace_range(f.range.clone(), &replacement);
svgs.push((key, Bytes::from(svg)));
}
Err(error) => {
tracing::warn!(
diagram = i,
total,
%error,
"mermaid diagram failed to render; leaving the fence as a code block"
);
}
}
}
svgs.reverse();
RenderedDiagrams {
markdown: out,
svgs,
}
}
fn is_mermaid(info: &str) -> bool {
info.split_whitespace()
.next()
.is_some_and(|t| t.eq_ignore_ascii_case("mermaid"))
}
#[cfg(test)]
mod tests {
use super::*;
const PIE: &str = "pie\n\"A\" : 1\n\"B\" : 2\n";
#[test]
fn renders_fence_to_image_ref_and_svg_bytes() {
let md = format!("# Title\n\n```mermaid\n{PIE}```\n\nAfter.\n");
let r = render_diagrams(&md);
assert_eq!(
r.markdown,
"# Title\n\n\n\nAfter.\n"
);
assert_eq!(r.svgs.len(), 1);
assert_eq!(r.svgs[0].0, "mermaid/diagram-0.svg");
assert!(r.svgs[0].1.starts_with(b"<svg"));
}
#[test]
fn non_mermaid_fences_are_untouched() {
let md = "```rust\nfn main() {}\n```\n";
let r = render_diagrams(md);
assert_eq!(r.markdown, md);
assert!(r.svgs.is_empty());
}
#[test]
fn no_fences_returns_input_unchanged() {
let md = "# Just text\n\nparagraph\n";
let r = render_diagrams(md);
assert_eq!(r.markdown, md);
assert!(r.svgs.is_empty());
}
#[test]
fn multiple_fences_keyed_in_document_order() {
let md = format!("```mermaid\n{PIE}```\n\nmiddle\n\n```mermaid\n{PIE}```\n");
let r = render_diagrams(&md);
assert_eq!(
r.markdown,
"\n\nmiddle\n\n\n"
);
let keys: Vec<&str> = r.svgs.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["mermaid/diagram-0.svg", "mermaid/diagram-1.svg"]);
}
#[test]
fn invalid_diagram_keeps_its_fence() {
let md = "```mermaid\nnot a diagram type at all\n```\n";
let r = render_diagrams(md);
assert_eq!(r.markdown, md);
assert!(r.svgs.is_empty());
}
#[test]
fn invalid_diagram_does_not_shift_keys_of_later_valid_ones() {
let md = format!("```mermaid\nnope nope\n```\n\n```mermaid\n{PIE}```\n");
let r = render_diagrams(&md);
assert!(r.markdown.starts_with("```mermaid\nnope nope\n```\n"));
assert!(r.markdown.contains(""));
assert_eq!(r.svgs.len(), 1);
assert_eq!(r.svgs[0].0, "mermaid/diagram-1.svg");
}
#[test]
fn info_string_attributes_and_case_are_tolerated() {
let md = format!("```Mermaid theme=dark\n{PIE}```\n");
let r = render_diagrams(&md);
assert_eq!(r.markdown, "\n");
assert_eq!(r.svgs.len(), 1);
}
#[test]
fn tilde_fences_work_too() {
let md = format!("~~~mermaid\n{PIE}~~~\n");
let r = render_diagrams(&md);
assert_eq!(r.markdown, "\n");
assert_eq!(r.svgs.len(), 1);
}
}