Skip to main content

mdbook_excalidraw/
lib.rs

1use anyhow::Result;
2use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
3
4/// Escape HTML special characters to prevent XSS
5fn escape_html(s: &str) -> String {
6    let mut output = String::new();
7    for c in s.chars() {
8        match c {
9            '<' => output.push_str("&lt;"),
10            '>' => output.push_str("&gt;"),
11            '"' => output.push_str("&quot;"),
12            '&' => output.push_str("&amp;"),
13            '\'' => output.push_str("&#39;"),
14            _ => output.push(c),
15        }
16    }
17    output
18}
19
20/// Process markdown content and convert mermaid code blocks to excalidraw format
21pub fn add_excalidraw(content: &str) -> Result<String> {
22    let mut mermaid_blocks = Vec::new();
23
24    let parser = Parser::new(content);
25    let mut in_mermaid_block = false;
26    let mut current_mermaid = String::new();
27
28    for (event, range) in parser.into_offset_iter() {
29        match event {
30            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
31                if lang.as_ref() == "mermaid" {
32                    in_mermaid_block = true;
33                    current_mermaid.clear();
34                }
35            }
36            Event::End(TagEnd::CodeBlock) => {
37                if in_mermaid_block {
38                    in_mermaid_block = false;
39
40                    // Generate unique ID for this diagram
41                    let diagram_id = format!("excalidraw-{}", mermaid_blocks.len());
42
43                    // Escape the mermaid content for security
44                    let escaped_mermaid = escape_html(&current_mermaid);
45
46                    // For the data attribute, also replace newlines with &#10; to keep it valid HTML
47                    let escaped_for_attribute = escaped_mermaid.replace('\n', "&#10;");
48
49                    // Create the excalidraw container with embedded mermaid data
50                    let excalidraw_html = format!(
51                        r#"<div class="excalidraw-wrapper" id="{id}">
52  <div class="excalidraw-container" data-mermaid="{mermaid}">
53    <div class="excalidraw-loading">Loading Excalidraw diagram...</div>
54  </div>
55  <details class="excalidraw-source">
56    <summary>View Mermaid Source</summary>
57    <pre><code class="language-mermaid">{source}</code></pre>
58  </details>
59</div>"#,
60                        id = diagram_id,
61                        mermaid = escaped_for_attribute,
62                        source = escaped_mermaid
63                    );
64
65                    mermaid_blocks.push((range.start..range.end, excalidraw_html));
66                    current_mermaid.clear();
67                }
68            }
69            Event::Text(text) => {
70                if in_mermaid_block {
71                    current_mermaid.push_str(&text);
72                }
73            }
74            Event::Code(text) => {
75                if in_mermaid_block {
76                    current_mermaid.push_str(&text);
77                }
78            }
79            Event::SoftBreak => {
80                if in_mermaid_block {
81                    current_mermaid.push('\n');
82                }
83            }
84            Event::HardBreak => {
85                if in_mermaid_block {
86                    current_mermaid.push('\n');
87                }
88            }
89            Event::Html(html) => {
90                if in_mermaid_block {
91                    current_mermaid.push_str(&html);
92                }
93            }
94            _ => {}
95        }
96    }
97
98    // Replace mermaid blocks with excalidraw HTML (forward order, using original offsets)
99    let mut result = String::with_capacity(content.len());
100    let mut last_end = 0;
101    for (span, block) in mermaid_blocks.iter() {
102        result.push_str(&content[last_end..span.start]);
103        result.push_str(block);
104        last_end = span.end;
105    }
106    result.push_str(&content[last_end..]);
107
108    Ok(result)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_escape_html() {
117        assert_eq!(
118            escape_html("<script>alert('XSS')</script>"),
119            "&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;"
120        );
121    }
122
123    #[test]
124    fn test_add_excalidraw_simple() {
125        let content = r#"# Test
126
127```mermaid
128graph TD
129    A-->B
130```
131
132Some text"#;
133
134        let result = add_excalidraw(content).unwrap();
135        assert!(result.contains("excalidraw-wrapper"));
136        assert!(result.contains("data-mermaid"));
137        assert!(result.contains("graph TD"));
138    }
139
140    #[test]
141    fn test_add_excalidraw_with_special_chars() {
142        let content = r#"```mermaid
143graph TD
144    A["<Component>"]-->B
145```"#;
146
147        let result = add_excalidraw(content).unwrap();
148        assert!(result.contains("&lt;Component&gt;"));
149    }
150
151    #[test]
152    fn test_multiple_mermaid_blocks() {
153        let content = r#"# Test
154
155```mermaid
156graph TD
157    A-->B
158```
159
160Some text
161
162```mermaid
163sequenceDiagram
164    Alice->>Bob: Hello
165```"#;
166
167        let result = add_excalidraw(content).unwrap();
168        assert!(result.contains("excalidraw-0"));
169        assert!(result.contains("excalidraw-1"));
170    }
171
172    #[test]
173    fn test_non_mermaid_blocks_unchanged() {
174        let content = r#"```rust
175fn main() {
176    println!("Hello");
177}
178```"#;
179
180        let result = add_excalidraw(content).unwrap();
181        assert!(result.contains("```rust"));
182        assert!(!result.contains("excalidraw"));
183    }
184}