mdbook_mermaid/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use mdbook_preprocessor::book::{Book, BookItem, Chapter};
6use mdbook_preprocessor::errors::Result;
7use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
8use pulldown_cmark::{CodeBlockKind::*, Event, Options, Parser, Tag, TagEnd};
9
10pub struct Mermaid;
11
12impl Preprocessor for Mermaid {
13    fn name(&self) -> &str {
14        "mermaid"
15    }
16
17    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
18        let mut res = None;
19        book.for_each_mut(|item: &mut BookItem| {
20            if let Some(Err(_)) = res {
21                return;
22            }
23
24            if let BookItem::Chapter(ref mut chapter) = *item {
25                res = Some(Mermaid::add_mermaid(chapter).map(|md| {
26                    chapter.content = md;
27                }));
28            }
29        });
30
31        res.unwrap_or(Ok(())).map(|_| book)
32    }
33
34    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
35        Ok(renderer == "html")
36    }
37}
38
39fn escape_html(s: &str) -> String {
40    let mut output = String::new();
41    for c in s.chars() {
42        match c {
43            '<' => output.push_str("&lt;"),
44            '>' => output.push_str("&gt;"),
45            '"' => output.push_str("&quot;"),
46            '&' => output.push_str("&amp;"),
47            _ => output.push(c),
48        }
49    }
50    output
51}
52
53fn add_mermaid(content: &str) -> Result<String> {
54    let mut mermaid_content = String::new();
55    let mut in_mermaid_block = false;
56
57    let mut opts = Options::empty();
58    opts.insert(Options::ENABLE_TABLES);
59    opts.insert(Options::ENABLE_FOOTNOTES);
60    opts.insert(Options::ENABLE_STRIKETHROUGH);
61    opts.insert(Options::ENABLE_TASKLISTS);
62
63    let mut code_span = 0..0;
64    let mut start_new_code_span = true;
65
66    let mut mermaid_blocks = vec![];
67
68    let events = Parser::new_ext(content, opts);
69    for (e, span) in events.into_offset_iter() {
70        log::debug!("e={:?}, span={:?}", e, span);
71        if let Event::Start(Tag::CodeBlock(Fenced(code))) = e.clone() {
72            if &*code == "mermaid" {
73                in_mermaid_block = true;
74                mermaid_content.clear();
75            }
76            continue;
77        }
78
79        if !in_mermaid_block {
80            continue;
81        }
82
83        // We're in the code block. The text is what we want.
84        // Code blocks can come in multiple text events.
85        if let Event::Text(_) = e {
86            if start_new_code_span {
87                code_span = span;
88                start_new_code_span = false;
89            } else {
90                code_span = code_span.start..span.end;
91            }
92
93            continue;
94        }
95
96        if let Event::End(TagEnd::CodeBlock) = e {
97            in_mermaid_block = false;
98
99            let mermaid_content = &content[code_span.clone()];
100            let mermaid_content = escape_html(mermaid_content);
101            let mermaid_content = mermaid_content.replace("\r\n", "\n");
102            let mermaid_code = format!("<pre class=\"mermaid\">{}</pre>\n\n", mermaid_content);
103            mermaid_blocks.push((span, mermaid_code));
104            start_new_code_span = true;
105        }
106    }
107
108    let mut content = content.to_string();
109    for (span, block) in mermaid_blocks.iter().rev() {
110        let pre_content = &content[0..span.start];
111        let post_content = &content[span.end..];
112        content = format!("{}\n{}{}", pre_content, block, post_content);
113    }
114    Ok(content)
115}
116
117impl Mermaid {
118    fn add_mermaid(chapter: &mut Chapter) -> Result<String> {
119        add_mermaid(&chapter.content)
120    }
121}
122
123#[cfg(test)]
124mod test {
125    use pretty_assertions::assert_eq;
126
127    use super::add_mermaid;
128
129    #[test]
130    fn adds_mermaid() {
131        let content = r#"# Chapter
132
133```mermaid
134graph TD
135A --> B
136```
137
138Text
139"#;
140
141        let expected = r#"# Chapter
142
143
144<pre class="mermaid">graph TD
145A --&gt; B
146</pre>
147
148
149
150Text
151"#;
152
153        assert_eq!(expected, add_mermaid(content).unwrap());
154    }
155
156    #[test]
157    fn leaves_tables_untouched() {
158        // Regression test.
159        // Previously we forgot to enable the same markdwon extensions as mdbook itself.
160
161        let content = r#"# Heading
162
163| Head 1 | Head 2 |
164|--------|--------|
165| Row 1  | Row 2  |
166"#;
167
168        let expected = r#"# Heading
169
170| Head 1 | Head 2 |
171|--------|--------|
172| Row 1  | Row 2  |
173"#;
174
175        assert_eq!(expected, add_mermaid(content).unwrap());
176    }
177
178    #[test]
179    fn leaves_html_untouched() {
180        // Regression test.
181        // Don't remove important newlines for syntax nested inside HTML
182
183        let content = r#"# Heading
184
185<del>
186
187*foo*
188
189</del>
190"#;
191
192        let expected = r#"# Heading
193
194<del>
195
196*foo*
197
198</del>
199"#;
200
201        assert_eq!(expected, add_mermaid(content).unwrap());
202    }
203
204    #[test]
205    fn html_in_list() {
206        // Regression test.
207        // Don't remove important newlines for syntax nested inside HTML
208
209        let content = r#"# Heading
210
2111. paragraph 1
212   ```
213   code 1
214   ```
2152. paragraph 2
216"#;
217
218        let expected = r#"# Heading
219
2201. paragraph 1
221   ```
222   code 1
223   ```
2242. paragraph 2
225"#;
226
227        assert_eq!(expected, add_mermaid(content).unwrap());
228    }
229
230    #[test]
231    fn escape_in_mermaid_block() {
232        let _ = env_logger::try_init();
233        let content = r#"
234```mermaid
235classDiagram
236    class PingUploader {
237        <<interface>>
238        +Upload() UploadResult
239    }
240```
241
242hello
243"#;
244
245        let expected = r#"
246
247<pre class="mermaid">classDiagram
248    class PingUploader {
249        &lt;&lt;interface&gt;&gt;
250        +Upload() UploadResult
251    }
252</pre>
253
254
255
256hello
257"#;
258
259        assert_eq!(expected, add_mermaid(content).unwrap());
260    }
261
262    #[test]
263    fn more_backticks() {
264        let _ = env_logger::try_init();
265        let content = r#"# Chapter
266
267````mermaid
268graph TD
269A --> B
270````
271
272Text
273"#;
274
275        let expected = r#"# Chapter
276
277
278<pre class="mermaid">graph TD
279A --&gt; B
280</pre>
281
282
283
284Text
285"#;
286
287        assert_eq!(expected, add_mermaid(content).unwrap());
288    }
289
290    #[test]
291    fn crlf_line_endings() {
292        let _ = env_logger::try_init();
293        let content = "# Chapter\r\n\r\n````mermaid\r\n\r\ngraph TD\r\nA --> B\r\n````";
294        let expected =
295            "# Chapter\r\n\r\n\n<pre class=\"mermaid\">\ngraph TD\nA --&gt; B\n</pre>\n\n";
296
297        assert_eq!(expected, add_mermaid(content).unwrap());
298    }
299}