mdx_gen/diagrams.rs
1//! Mermaid diagram rendering for fenced code blocks tagged
2//! `mermaid`.
3//!
4//! Rather than rasterising server-side (which would require a
5//! headless browser or equivalent heavy runtime), mdx-gen rewrites
6//! each `mermaid` code block into a sanitizer-safe
7//! `<pre class="mermaid">…</pre>` container that the client-side
8//! [mermaid.js] library hydrates into inline SVG at page-load
9//! time. This matches what github.com does natively in README
10//! rendering.
11//!
12//! # Usage
13//!
14//! Enable the transform with
15//! [`crate::MarkdownOptions::with_diagrams`]. Emit the hydration
16//! script into your page shell exactly once with
17//! [`hydration_script_html`]. The output is SVG.
18//!
19//! ```
20//! use mdx_gen::{process_markdown, MarkdownOptions};
21//!
22//! let md = "```mermaid\ngraph TD\nA --> B\n```\n";
23//! let options = MarkdownOptions::new()
24//! .with_custom_blocks(false)
25//! .with_enhanced_tables(false)
26//! .with_syntax_highlighting(false)
27//! .with_diagrams(true);
28//! let html = process_markdown(md, &options).unwrap();
29//! assert!(html.contains("<pre class=\"mermaid\">"));
30//! ```
31//!
32//! [mermaid.js]: https://mermaid.js.org/
33
34use comrak::nodes::{AstNode, NodeHtmlBlock, NodeValue};
35
36/// Walks the comrak AST and replaces every `NodeValue::CodeBlock`
37/// whose info-string names `mermaid` with a
38/// `NodeValue::HtmlBlock` containing the sanitizer-safe
39/// `<pre class="mermaid">` container.
40///
41/// Kind-matching is done on the first whitespace-delimited token
42/// of the info string so `mermaid classDiagram` still counts as
43/// mermaid. Non-mermaid code blocks pass through unchanged — the
44/// syntax highlighter still sees them downstream.
45pub fn process_diagram_code_blocks<'a>(root: &'a AstNode<'a>) {
46 for node in root.descendants() {
47 let mut ast = node.data.borrow_mut();
48 let replacement = match ast.value {
49 NodeValue::CodeBlock(ref block) => {
50 let kind = block
51 .info
52 .split_whitespace()
53 .next()
54 .unwrap_or("")
55 .to_ascii_lowercase();
56 if kind == "mermaid" {
57 Some(render_mermaid(&block.literal))
58 } else {
59 None
60 }
61 }
62 _ => None,
63 };
64 if let Some(html) = replacement {
65 ast.value = NodeValue::HtmlBlock(NodeHtmlBlock {
66 block_type: 6,
67 literal: html,
68 });
69 }
70 }
71}
72
73/// Builds the `<pre class="mermaid">` container for a single
74/// block. Content is HTML-escaped so raw `<` / `>` inside the
75/// source cannot break out of the `<pre>` context.
76fn render_mermaid(source: &str) -> String {
77 let escaped = html_escape::encode_text(source);
78 format!("<pre class=\"mermaid\">{escaped}</pre>\n")
79}
80
81/// Returns the `<script type="module">…</script>` block users
82/// should drop into their page shell (usually just before
83/// `</body>`) to hydrate every `<pre class="mermaid">` container
84/// on the page. The script is safe to include on pages that have
85/// no diagrams — it short-circuits when no mermaid container is
86/// present.
87///
88/// The return value is a `'static` string; embed it verbatim.
89#[must_use]
90pub fn hydration_script_html() -> &'static str {
91 HYDRATION_SCRIPT
92}
93
94const HYDRATION_SCRIPT: &str = include_str!("diagrams_hydrator.js");
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use comrak::{parse_document, Arena, Options};
100
101 /// Walk the root looking for an HtmlBlock whose literal
102 /// contains `needle`. Returns the matching literal or `None`.
103 fn find_html_containing<'a>(
104 root: &'a AstNode<'a>,
105 needle: &str,
106 ) -> Option<String> {
107 for node in root.descendants() {
108 if let NodeValue::HtmlBlock(ref block) =
109 node.data.borrow().value
110 {
111 if block.literal.contains(needle) {
112 return Some(block.literal.clone());
113 }
114 }
115 }
116 None
117 }
118
119 /// Helper that parses Markdown, runs the diagram transform,
120 /// and returns any HtmlBlock literal containing `needle`.
121 fn transform_and_find(
122 source: &str,
123 needle: &str,
124 ) -> Option<String> {
125 let arena = Arena::new();
126 let root = parse_document(&arena, source, &Options::default());
127 process_diagram_code_blocks(root);
128 find_html_containing(root, needle)
129 }
130
131 #[test]
132 fn test_mermaid_block_rewritten() {
133 let md = "```mermaid\ngraph TD\n A --> B\n```\n";
134 let found = transform_and_find(md, "class=\"mermaid\"")
135 .expect("mermaid container missing");
136 assert!(found.starts_with("<pre class=\"mermaid\">"));
137 assert!(found.contains("graph TD"));
138 assert!(found.contains("A --> B"), "content escaped");
139 }
140
141 #[test]
142 fn test_info_string_with_attributes() {
143 // `mermaid classDiagram` should still match on the first
144 // whitespace-delimited token.
145 let md =
146 "```mermaid classDiagram\nclassDiagram\n A<|--B\n```\n";
147 assert!(transform_and_find(md, "class=\"mermaid\"").is_some());
148 }
149
150 #[test]
151 fn test_unknown_lang_passes_through() {
152 let md = "```rust\nfn main() {}\n```\n";
153 assert!(transform_and_find(md, "class=\"mermaid\"").is_none());
154 }
155
156 #[test]
157 fn test_non_matching_diagram_langs_pass_through() {
158 // Formats that previously had first-class support (geojson,
159 // topojson, stl) are no longer recognised — they should
160 // pass through to the syntax highlighter like any other
161 // unknown language.
162 for lang in ["geojson", "topojson", "stl"] {
163 let md = format!("```{lang}\n{{\"a\":1}}\n```\n");
164 assert!(
165 transform_and_find(&md, "class=\"mermaid\"").is_none(),
166 "{lang} should not produce a mermaid container"
167 );
168 }
169 }
170
171 #[test]
172 fn test_content_is_html_escaped() {
173 let md = "```mermaid\ngraph <script>alert(1)</script>\n```\n";
174 let found =
175 transform_and_find(md, "class=\"mermaid\"").unwrap();
176 assert!(!found.contains("<script>"));
177 assert!(found.contains("<script>"));
178 }
179
180 #[test]
181 fn test_hydration_script_imports_mermaid() {
182 let s = hydration_script_html();
183 assert!(s.contains("pre.mermaid"));
184 assert!(s.contains("mermaid"));
185 // Wrapped in <script type="module">…</script>.
186 assert!(s.starts_with("<script type=\"module\">"));
187 assert!(s.trim_end().ends_with("</script>"));
188 }
189}