mdbook_template/
lib.rs

1use std::path::Path;
2
3use log::{error, warn};
4use mdbook::book::Book;
5use mdbook::errors::Result;
6use mdbook::preprocess::{Preprocessor, PreprocessorContext};
7use mdbook::BookItem;
8
9use crate::utils::{FileReader, SystemFileReader};
10
11mod links;
12pub mod utils;
13
14const MAX_LINK_NESTED_DEPTH: usize = 10;
15
16#[derive(Default)]
17pub struct Template;
18
19impl Template {
20    pub fn new() -> Self {
21        Template
22    }
23}
24
25impl Preprocessor for Template {
26    fn name(&self) -> &str {
27        "template"
28    }
29
30    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
31        env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
32        let src_dir = ctx.root.join(&ctx.config.book.src);
33
34        book.for_each_mut(|section| {
35            if let BookItem::Chapter(ref mut chapter) = section {
36                if let Some(ref source) = chapter.path {
37                    let base = source
38                        .parent()
39                        .map(|dir| src_dir.join(dir))
40                        .expect("All book items have a parent");
41
42                    let content =
43                        replace_template(&chapter.content, &SystemFileReader, base, source, 0);
44                    chapter.content = content;
45                }
46            }
47        });
48
49        Ok(book)
50    }
51
52    fn supports_renderer(&self, renderer: &str) -> bool {
53        renderer == "html"
54    }
55}
56
57pub fn replace_template<P1, P2, FR>(
58    chapter_content: &str,
59    file_reader: &FR,
60    base: P1,
61    source: P2,
62    depth: usize,
63) -> String
64where
65    P1: AsRef<Path>,
66    P2: AsRef<Path>,
67    FR: FileReader,
68{
69    let path = base.as_ref();
70    let source = source.as_ref();
71    // Must keep track of indices as they will not correspond after string substitution
72    let mut previous_end_index = 0;
73    let mut replaced = String::with_capacity(chapter_content.len());
74
75    for link in links::extract_template_links(chapter_content) {
76        replaced.push_str(&chapter_content[previous_end_index..link.start_index]);
77
78        match link.replace_args(path, file_reader) {
79            Ok(new_content) => {
80                if depth < MAX_LINK_NESTED_DEPTH {
81                    if let Some(rel_path) = link.link_type.relative_path(path) {
82                        replaced.push_str(&replace_template(
83                            &new_content,
84                            file_reader,
85                            rel_path,
86                            source,
87                            depth + 1,
88                        ));
89                    } else {
90                        replaced.push_str(&new_content);
91                    }
92                } else {
93                    error!(
94                        "Stack Overflow! {}. Check For Cyclic Templates",
95                        source.display()
96                    );
97                }
98                previous_end_index = link.end_index;
99            }
100            Err(err) => {
101                error!("Error updating \"{}\", {}", link.link_text, err);
102                for cause in err.chain().skip(1) {
103                    warn!("Caused By: {}", cause);
104                }
105
106                // Include `{{# ... }}` snippet when errors occur
107                previous_end_index = link.start_index;
108            }
109        }
110    }
111
112    replaced.push_str(&chapter_content[previous_end_index..]);
113    replaced
114}
115
116#[cfg(test)]
117mod lib_tests {
118    use std::collections::HashMap;
119    use std::path::PathBuf;
120
121    use crate::replace_template;
122    use crate::utils::TestFileReader;
123
124    #[test]
125    fn test_happy_path_escaped() {
126        let start = r"
127        Example Text
128        ```hbs
129        \{{#template template.md}} << an escaped link!
130        ```";
131        let end = r"
132        Example Text
133        ```hbs
134        {{#template template.md}} << an escaped link!
135        ```";
136
137        assert_eq!(
138            replace_template(start, &TestFileReader::default(), "", "", 0),
139            end
140        );
141    }
142
143    #[test]
144    fn test_happy_path_simple() {
145        let start_chapter_content = "{{#template footer.md}}";
146        let end_chapter_content = "Designed & Created With Love From - Goudham & Hazel";
147        let file_name = PathBuf::from("footer.md");
148        let template_file_contents =
149            "Designed & Created With Love From - Goudham & Hazel".to_string();
150        let map = HashMap::from([(file_name, template_file_contents)]);
151        let file_reader = &TestFileReader::from(map);
152
153        let actual_chapter_content =
154            replace_template(start_chapter_content, file_reader, "", "", 0);
155
156        assert_eq!(actual_chapter_content, end_chapter_content);
157    }
158
159    #[test]
160    fn test_happy_path_with_args() {
161        let start_chapter_content = "{{#template footer.md authors=Goudham & Hazel}}";
162        let end_chapter_content = "Designed & Created With Love From - Goudham & Hazel";
163        let file_name = PathBuf::from("footer.md");
164        let template_file_contents = "Designed & Created With Love From - [[#authors]]".to_string();
165        let map = HashMap::from([(file_name, template_file_contents)]);
166        let file_reader = &TestFileReader::from(map);
167
168        let actual_chapter_content =
169            replace_template(start_chapter_content, file_reader, "", "", 0);
170
171        assert_eq!(actual_chapter_content, end_chapter_content);
172    }
173
174    #[test]
175    fn test_happy_path_new_lines() {
176        let start_chapter_content = r"
177        Some content...
178        {{#template footer.md authors=Goudham & Hazel}}";
179        let end_chapter_content = r"
180        Some content...
181        - - - -
182        Designed & Created With Love From Goudham & Hazel";
183        let file_name = PathBuf::from("footer.md");
184        let template_file_contents = r"- - - -
185        Designed & Created With Love From [[#authors]]"
186            .to_string();
187        let map = HashMap::from([(file_name, template_file_contents)]);
188        let file_reader = &TestFileReader::from(map);
189
190        let actual_chapter_content =
191            replace_template(start_chapter_content, file_reader, "", "", 0);
192
193        assert_eq!(actual_chapter_content, end_chapter_content);
194    }
195
196    #[test]
197    fn test_happy_path_multiple() {
198        let start_chapter_content = r"
199        {{#template header.md title=Example Title}}
200        Some content...
201        {{#template
202            footer.md
203        authors=Goudham & Hazel}}";
204        let end_chapter_content = r"
205        # Example Title
206        Some content...
207        - - - -
208        Designed & Created With Love From Goudham & Hazel";
209        let header_file_name = PathBuf::from("header.md");
210        let header_contents = r"# [[#title]]".to_string();
211        let footer_file_name = PathBuf::from("footer.md");
212        let footer_contents = r"- - - -
213        Designed & Created With Love From [[#authors]]"
214            .to_string();
215        let map = HashMap::from([
216            (footer_file_name, footer_contents),
217            (header_file_name, header_contents),
218        ]);
219        let file_reader = &TestFileReader::from(map);
220
221        let actual_chapter_content =
222            replace_template(start_chapter_content, file_reader, "", "", 0);
223
224        assert_eq!(actual_chapter_content, end_chapter_content);
225    }
226
227    #[test]
228    fn test_happy_path_with_default_values() {
229        let start_chapter_content = "{{#template footer.md}}";
230        let end_chapter_content = "Designed By - Goudham";
231        let file_name = PathBuf::from("footer.md");
232        let template_file_contents = "Designed By - [[#authors Goudham]]".to_string();
233        let map = HashMap::from([(file_name, template_file_contents)]);
234        let file_reader = &TestFileReader::from(map);
235
236        let actual_chapter_content =
237            replace_template(start_chapter_content, file_reader, "", "", 0);
238
239        assert_eq!(actual_chapter_content, end_chapter_content);
240    }
241
242    #[test]
243    fn test_happy_path_with_overridden_default_values() {
244        let start_chapter_content = "{{#template footer.md authors=Hazel}}";
245        let end_chapter_content = "Designed By - Hazel";
246        let file_name = PathBuf::from("footer.md");
247        let template_file_contents = "Designed By - [[#authors Goudham]]".to_string();
248        let map = HashMap::from([(file_name, template_file_contents)]);
249        let file_reader = &TestFileReader::from(map);
250
251        let actual_chapter_content =
252            replace_template(start_chapter_content, file_reader, "", "", 0);
253
254        assert_eq!(actual_chapter_content, end_chapter_content);
255    }
256
257    #[test]
258    fn test_happy_path_nested() {
259        let start_chapter_content = r"
260        {{#template header.md title=Example Title}}
261        Some content...";
262        let end_chapter_content = r"
263        # Example Title
264        <img src='example.png' alt='Example Title'>
265        Some content...";
266        let header_file_name = PathBuf::from("header.md");
267        let header_contents = r"# [[#title]]
268        {{#template image.md title=[[#title]]}}"
269            .to_string();
270        let image_file_name = PathBuf::from("image.md");
271        let image_contents = r"<img src='example.png' alt='[[#title]]'>".to_string();
272        let map = HashMap::from([
273            (image_file_name, image_contents),
274            (header_file_name, header_contents),
275        ]);
276        let file_reader = &TestFileReader::from(map);
277
278        let actual_chapter_content =
279            replace_template(start_chapter_content, file_reader, "", "", 0);
280
281        assert_eq!(actual_chapter_content, end_chapter_content);
282    }
283
284    #[test]
285    fn test_sad_path_invalid_file() {
286        env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
287
288        let start_chapter_content = "{{#template footer.md}}";
289
290        let actual_chapter_content =
291            replace_template(start_chapter_content, &TestFileReader::default(), "", "", 0);
292
293        assert_eq!(actual_chapter_content, start_chapter_content);
294    }
295
296    #[test]
297    fn test_sad_path_bad_template() {
298        let start_chapter_content = [
299            "This is {{#template template.md",
300            "text=valid text",
301            "this has no key for the value and is going to break things}}",
302        ]
303        .join("\n");
304        let end_chapter_content = "This is valid text";
305        let file_name: PathBuf = PathBuf::from("template.md");
306        let template_file_contents = "[[#text]]".to_string();
307        let map = HashMap::from([(file_name, template_file_contents)]);
308        let file_reader = &TestFileReader::from(map);
309
310        let actual_chapter_content =
311            replace_template(&start_chapter_content, file_reader, "", "", 0);
312
313        assert_eq!(actual_chapter_content, end_chapter_content);
314    }
315}