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 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 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}