Skip to main content

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 --&gt; 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("&lt;script&gt;"));
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}