mdbook_multicode/
lib.rs

1use std::collections::HashMap;
2
3use mdbook::book::Book;
4use mdbook::errors::Error;
5use mdbook::preprocess::{Preprocessor, PreprocessorContext};
6use mdbook::BookItem;
7use regex::Regex;
8
9pub struct Multicode {
10    multicode_regex: Regex,
11    end_multicode: Regex,
12    code_start: Regex,
13    code_end: Regex,
14}
15
16pub enum ParseState {
17    Nothing,
18    Multicode,
19    Code(String),
20}
21
22impl Multicode {
23    pub fn new() -> Multicode {
24        Multicode {
25            multicode_regex: Regex::new(r"^```multicode$").unwrap(),
26            end_multicode: Regex::new(r"^```$").unwrap(),
27            code_start: Regex::new(r"^>>>>> ([a-zA-Z0-9]+)$").unwrap(),
28            code_end: Regex::new(r"^<<<<<$").unwrap(),
29        }
30    }
31}
32
33impl Preprocessor for Multicode {
34    fn name(&self) -> &str {
35        "http-api"
36    }
37
38    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
39        // In testing we want to tell the preprocessor to blow up by setting a
40        // particular config value
41        if let Some(our_cfg) = ctx.config.get_preprocessor(self.name()) {
42            if our_cfg.contains_key("blow-up") {
43                anyhow::bail!("Blowing up!");
44            }
45        }
46
47        book.for_each_mut(|book_item| {
48            match book_item {
49                BookItem::Separator => {}
50                BookItem::PartTitle(_) => {}
51                BookItem::Chapter(chapter) => {
52                    let lines = chapter.content.lines();
53                    let mut lang_example_no = 0usize;
54                    let mut langs = Vec::new();
55                    let mut lang_texts: HashMap<String, String> = HashMap::default();
56                    let mut new_content = String::new();
57
58                    new_content.push_str(include_str!("script_template.html"));
59                    new_content.push('\n');
60
61                    let mut parse_state = ParseState::Nothing;
62                    for line in lines {
63                        match &parse_state {
64                            ParseState::Nothing => {
65                                if self.multicode_regex.is_match(line) {
66                                    parse_state = ParseState::Multicode;
67                                    new_content.push('\n');
68                                } else {
69                                    new_content.push_str(line);
70                                    new_content.push('\n');
71                                }
72                            }
73                            ParseState::Multicode => {
74                                if self.end_multicode.is_match(line) {
75                                    parse_state = ParseState::Nothing;
76
77                                    if !langs.is_empty() {
78                                        let example_class_name = format!("code-example-tab-{lang_example_no}");
79
80                                        let lang_select_options = langs
81                                            .iter()
82                                            .map(|lang| format!(
83                                                r#"<option value="{example_class_name}-{lang}">{lang}</option>"#
84                                            ))
85                                            .fold(String::new(), |mut acc, s| {
86                                                acc.push_str(&s);
87                                                acc
88                                            });
89                                        let first_lang = langs.first().unwrap();
90
91                                        new_content.push_str(&format!(
92                                            r#"<div><select onchange="changeCodeExample('{example_class_name}', event.target.value)" value="{first_lang}" class="code-example" autocomplete="off">"#
93                                        ));
94                                        new_content.push_str(&lang_select_options);
95                                        new_content.push_str(r#"</select></div>"#);
96                                        new_content.push('\n');
97
98                                        for lang in &langs {
99                                            let lang_text = lang_texts.get(lang).unwrap();
100                                            new_content.push_str(&format!(
101                                                r#"<div id="{example_class_name}-{lang}" class="{example_class_name}"><pre><code class="language-{lang}">"#
102                                            ));
103                                            new_content.push_str(&html_escape(lang_text));
104                                            new_content.push_str(r#"</code></pre></div>"#);
105                                        }
106
107                                        new_content.push_str(&format!(
108                                            r#"<script>(()=>{{changeCodeExample("{example_class_name}", "{example_class_name}-{first_lang}")}})()</script>"#
109                                        ));
110
111                                        // Blank line, finally
112                                        new_content.push_str("\n\n");
113                                    }
114                                    lang_example_no += 1;
115                                } else if let Some(captures) = self.code_start.captures(line) {
116                                    let lang_name = captures.get(1).unwrap().as_str().to_owned();
117                                    langs.push(lang_name.clone());
118                                    lang_texts.insert(lang_name.clone(), String::new());
119                                    parse_state = ParseState::Code(lang_name);
120                                }
121                            }
122                            ParseState::Code(language) => {
123                                if self.code_end.is_match(line) {
124                                    parse_state = ParseState::Multicode;
125                                } else {
126                                    let lang_text = lang_texts.get_mut(language).unwrap();
127                                    lang_text.push_str(line);
128                                    lang_text.push('\n');
129                                }
130                            }
131                        }
132                    } // End of parsing
133
134                    chapter.content = new_content;
135                }
136            }
137        });
138
139        // we *are* a no-op preprocessor after all
140        Ok(book)
141    }
142
143    fn supports_renderer(&self, renderer: &str) -> bool {
144        renderer == "html" || renderer == "markdown"
145    }
146}
147
148fn html_escape(text_to_escape: impl AsRef<str>) -> String {
149    let mut text = text_to_escape.as_ref().to_string();
150    text = text.replace('&', "&amp;");
151    text = text.replace('<', "&lt;");
152    text = text.replace('>', "&gt;");
153    text = text.replace('"', "&quot;");
154    text = text.replace('\'', "&#39;");
155    // Empty lines are cursed on markdown+HTML, so cursed I had to do this
156    // The empty emphasis text should render as nothing, but avoid leaving a
157    // blank line on the markdown book
158    text = text.replace("\n\n", "\n<em></em>\n");
159    text
160}
161
162// #[cfg(test)]
163// mod test {
164//     use super::*;
165
166//     #[test]
167//     fn preprocessor_run() {
168//         let input_json = r##"[
169//             {
170//                 "root": "/path/to/book",
171//                 "config": {
172//                     "book": {
173//                         "authors": ["AUTHOR"],
174//                         "language": "en",
175//                         "multilingual": false,
176//                         "src": "src",
177//                         "title": "TITLE"
178//                     },
179//                     "preprocessor": {
180//                         "http-api": {
181//                         }
182//                     }
183//                 },
184//                 "renderer": "html",
185//                 "mdbook_version": "0.4.21"
186//             },
187//             {
188//                 "sections": [
189//                     {
190//                         "Chapter": {
191//                             "name": "Chapter 1",
192//                             "content": CONTENT_PLACEHOLDER_THINGIE_HERE,
193//                             "number": [1],
194//                             "sub_items": [],
195//                             "path": "chapter_1.md",
196//                             "source_path": "chapter_1.md",
197//                             "parent_names": []
198//                         }
199//                     }
200//                 ],
201//                 "__non_exhaustive": null
202//             }
203//         ]"##;
204//         let input_json = input_json.replace(
205//             "CONTENT_PLACEHOLDER_THINGIE_HERE",
206//             &format!("{:?}", include_str!("content_test_example.md")),
207//         );
208//         let input_json = input_json.as_bytes();
209
210//         let (ctx, book) = mdbook::preprocess::CmdPreprocessor::parse_input(input_json).unwrap();
211//         let mut expected_book = book.clone();
212//         let result = Multicode::new().run(&ctx, book);
213//         assert!(result.is_ok());
214
215//         if let BookItem::Chapter(c) = expected_book.sections.first_mut().as_mut().unwrap() {
216//             c.content.clear();
217//             c.content.push_str(&include_str!("script_template.html"));
218//             c.content.push('\n');
219//             c.content.push_str(&include_str!("content_test_example.md"));
220//         }
221
222//         // <div><select onchange=\"changeCodeExample('code-example-tab-0', event.target.value)\" value=\"rust\" class=\"code-example\"><option value=\"code-example-tab-0-rust\">rust</option><option value=\"code-example-tab-0-cpp\">cpp</option></select></div>\n<div id=\"code-example-tab-0-rust\" class=\"code-example-tab-0\"><pre><code class=\"language-rust\">fn id&lt;X&gt;(x: X) -&gt; {\n    x\n}\n</code></pre></div><div id=\"code-example-tab-0-cpp\" class=\"code-example-tab-0\"><pre><code class=\"language-cpp\">X id&lt;X&gt;(X x) {\n    return x;\n}\n</code></pre></div><script>(()=>{changeCodeExample(\"code-example-tab-0\", \"code-example-tab-0-rust\")})()</script>
223
224//         // The nop-preprocessor should not have made any changes to the book content.
225//         let actual_book = result.unwrap();
226//         assert_eq!(actual_book, expected_book);
227//     }
228// }