mdbook_d2/
lib.rs

1//! [D2] diagram generator [`Preprocessor`] library for [`MdBook`](https://rust-lang.github.io/mdBook/).
2
3#![deny(
4    clippy::all,
5    missing_debug_implementations,
6    missing_copy_implementations,
7    missing_docs
8)]
9#![warn(clippy::pedantic, clippy::nursery)]
10
11use mdbook_preprocessor::{
12    book::{Book, BookItem, Chapter},
13    errors::Error,
14    Preprocessor, PreprocessorContext,
15};
16use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd};
17use pulldown_cmark_to_cmark::{calculate_code_block_token_count, cmark_with_options};
18
19mod backend;
20use backend::{Backend, RenderContext};
21
22mod config;
23
24/// [D2] diagram generator [`Preprocessor`] for [`MdBook`](https://rust-lang.github.io/mdBook/).
25#[derive(Default, Clone, Copy, Debug)]
26pub struct D2;
27
28impl Preprocessor for D2 {
29    fn name(&self) -> &'static str {
30        "d2"
31    }
32
33    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
34        let backend = Backend::from_context(ctx);
35
36        book.for_each_mut(|section| {
37            if let BookItem::Chapter(chapter) = section {
38                let events: Vec<_> = process_events(
39                    &backend,
40                    chapter,
41                    Parser::new_ext(&chapter.content, Options::all()),
42                )
43                .collect();
44
45                // Determine the minimum number of backticks needed for code blocks.
46                // Use 3 (the CommonMark default) unless nested code blocks require more.
47                // This preserves the original markdown structure while correctly handling
48                // code blocks that contain other code block examples.
49                // See: https://github.com/danieleades/mdbook-d2/issues/170
50                let code_block_token_count =
51                    calculate_code_block_token_count(events.iter()).unwrap_or(3);
52
53                let options = pulldown_cmark_to_cmark::Options {
54                    code_block_token_count,
55                    ..Default::default()
56                };
57
58                // create a buffer in which we can place the markdown
59                let mut buf = String::with_capacity(chapter.content.len() + 128);
60
61                // convert it back to markdown and replace the original chapter's content
62                cmark_with_options(events.into_iter(), &mut buf, options).unwrap();
63                chapter.content = buf;
64            }
65        });
66
67        Ok(book)
68    }
69}
70
71fn process_events<'a>(
72    backend: &'a Backend,
73    chapter: &'a Chapter,
74    events: impl Iterator<Item = Event<'a>> + 'a,
75) -> impl Iterator<Item = Event<'a>> + 'a {
76    let mut in_block = false;
77    // if Windows crlf line endings are used, a code block will consist
78    // of many different Text blocks, thus we need to buffer them in here
79    // see https://github.com/raphlinus/pulldown-cmark/issues/507
80    let mut diagram = String::new();
81    let mut diagram_index = 0;
82
83    events.flat_map(move |event| {
84        match (&event, in_block) {
85            // check if we are entering a d2 codeblock
86            (
87                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::Borrowed("d2")))),
88                false,
89            ) => {
90                in_block = true;
91                diagram.clear();
92                diagram_index += 1;
93                vec![]
94            }
95            // check if we are currently inside a d2 block
96            (Event::Text(content), true) => {
97                diagram.push_str(content);
98                vec![]
99            }
100            // check if we are exiting a d2 block
101            (Event::End(TagEnd::CodeBlock), true) => {
102                in_block = false;
103                let render_context = RenderContext::new(
104                    chapter.source_path.as_ref().unwrap(),
105                    &chapter.name,
106                    chapter.number.as_ref(),
107                    diagram_index,
108                );
109                backend
110                    .render(&render_context, &diagram)
111                    .unwrap_or_else(|e| {
112                        // if we cannot render the diagram, print the error and return an empty
113                        // block
114                        eprintln!("{e}");
115                        vec![]
116                    })
117            }
118            // if nothing matches, change nothing
119            _ => vec![event],
120        }
121    })
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    /// Helper to round-trip markdown like the preprocessor does.
129    fn round_trip_markdown(input: &str) -> String {
130        let events: Vec<_> = Parser::new_ext(input, Options::all()).collect();
131        let code_block_token_count = calculate_code_block_token_count(events.iter()).unwrap_or(3);
132        let options = pulldown_cmark_to_cmark::Options {
133            code_block_token_count,
134            ..Default::default()
135        };
136        let mut output = String::new();
137        cmark_with_options(events.into_iter(), &mut output, options).unwrap();
138        output
139    }
140
141    /// Tests that code blocks preserve 3 backticks after round-trip conversion.
142    ///
143    /// This is a regression test for <https://github.com/danieleades/mdbook-d2/issues/170>.
144    /// When using the default pulldown-cmark-to-cmark options, code blocks
145    /// would be converted to use 4 backticks instead of 3, causing issues
146    /// with other preprocessors.
147    #[test]
148    fn code_blocks_preserve_backticks() {
149        let input = "```rust\nfn main() {}\n```\n";
150        let output = round_trip_markdown(input);
151
152        assert!(
153            output.contains("```rust"),
154            "expected 3 backticks, got: {output}"
155        );
156        assert!(
157            !output.contains("````"),
158            "should not have 4 backticks: {output}"
159        );
160    }
161
162    #[test]
163    fn multiple_code_blocks_preserve_backticks() {
164        let input = r#"# Title
165
166```rust
167fn main() {}
168```
169
170Some text.
171
172```python
173print("hello")
174```
175"#;
176
177        let output = round_trip_markdown(input);
178
179        // Count occurrences of ``` (but not ````)
180        let three_backticks = output.matches("```").count();
181        let four_backticks = output.matches("````").count();
182
183        assert_eq!(
184            three_backticks, 4,
185            "expected 4 occurrences of 3 backticks (2 code blocks × 2), got: {output}"
186        );
187        assert_eq!(
188            four_backticks, 0,
189            "should not have any 4 backticks: {output}"
190        );
191    }
192
193    /// Test that code blocks containing backticks are properly escaped with
194    /// more backticks.
195    #[test]
196    fn nested_code_blocks_escaped_correctly() {
197        // A code block containing a literal 3-backtick code block example
198        let input = r"Here's how to write a code block:
199
200````markdown
201```rust
202fn main() {}
203```
204````
205
206That's it!
207";
208
209        let output = round_trip_markdown(input);
210
211        // The outer code block should still use 4 backticks to escape the inner 3
212        assert!(
213            output.contains("````"),
214            "should have 4 backticks to escape nested block: {output}"
215        );
216        // The inner code block should be preserved as content
217        assert!(
218            output.contains("```rust"),
219            "inner code block should be preserved: {output}"
220        );
221    }
222}