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('&', "&");
151 text = text.replace('<', "<");
152 text = text.replace('>', ">");
153 text = text.replace('"', """);
154 text = text.replace('\'', "'");
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<X>(x: X) -> {\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<X>(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// }