mdbook_chapter_path/
lib.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use regex::{Regex, Captures};
5
6use mdbook::book::{Book, BookItem};
7use mdbook::errors::Error;
8use mdbook::preprocess::{Preprocessor, PreprocessorContext};
9
10pub struct PathProcessor;
11
12#[derive(Debug, Eq, PartialEq)]
13pub enum ProcessorError {
14    // Tried to provide path to the given chapter, but couldn't find one.
15    ChapterNotFound(String),
16    // Duplicate chapter names found. Only an issue when strict mode is on.
17    DuplicateChapterNames(String)
18}
19
20struct FileLink<'a> {
21    name: &'a str,
22    anchor: Option<&'a str>
23}
24
25struct PathProcessorOptions {
26    site_path: String,
27    strict_mode: bool
28}
29
30impl FileLink<'_> {
31    fn from_string(string: &str) -> FileLink {
32        let splitted: Vec<&str> = string.split("#").collect();
33
34        if splitted.len() > 2 {
35            panic!("Invalid link parsed: Multiple '#'s detected for {}", string);
36        }
37        let name = splitted[0];
38        let mut anchor: Option<&str> = None;
39        if splitted.len() == 2 {
40            anchor = Some(splitted[1]);
41        }
42
43        FileLink { name, anchor }
44    }
45}
46
47impl Preprocessor for PathProcessor {
48    fn name(&self) -> &str { "chapter-path" }
49
50    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
51        let options = self.process_options(ctx);
52
53        let known_chapters = self.chapter_names(&book, &options).unwrap();
54
55        book.for_each_mut(|item| {
56            if let BookItem::Chapter(chapter) = item {
57                chapter.content = self.process_chapter(&chapter.content, &known_chapters, &options).unwrap();
58            }
59        });
60        Ok(book)
61    }
62
63    fn supports_renderer(&self, renderer: &str) -> bool { renderer == "html" }
64}
65
66impl PathProcessor {
67    fn process_options(&self, ctx: &PreprocessorContext) -> PathProcessorOptions {
68        // process site_path
69        let mut site_path: String = "/".to_string();
70        if let Some(config) = ctx.config.get("output.html") {
71            if let Some(toml::value::Value::String(value)) = config.get("site-url") {
72                site_path = value.to_string();
73            }
74        }
75
76        if site_path.ends_with("/") == false {
77            site_path.push_str("/");
78        }
79
80        let mut strict_mode = false;
81        if let Some(config) = ctx.config.get_preprocessor("chapter-path") {
82            if let Some(toml::value::Value::Boolean(value)) = config.get("strict") {
83                strict_mode = *value;
84            }
85        }
86
87        PathProcessorOptions {
88            site_path,
89            strict_mode
90        }
91    }
92
93    fn chapter_names(&self, book: &Book, options: &PathProcessorOptions) -> Result<HashMap<String, PathBuf>, ProcessorError>{
94        let mut mapping: HashMap<String, PathBuf> = HashMap::new();
95
96        for item in book.iter() {
97            if let BookItem::Chapter(chapter) = item {
98                if let Option::Some(path) = &chapter.path {
99                    if let Some(existing_path) = mapping.get(&chapter.name.to_lowercase()) {
100                        if options.strict_mode {
101                            return Err(ProcessorError::DuplicateChapterNames(chapter.name.to_lowercase()));
102                        } else {
103                            eprintln!("Warning: Found duplicate chapter name {} at {} (existing chapter at {})", chapter.name, path.to_str().unwrap(), existing_path.to_str().unwrap());
104                        }
105                    }
106                    mapping.insert(chapter.name.to_lowercase(), path.to_path_buf());
107                }
108            }
109        };
110        Ok(mapping)
111    }
112
113    fn process_chapter(&self, content: &str, chapter_names: &HashMap<String, PathBuf>, options: &PathProcessorOptions) -> Result<String, ProcessorError> {
114        let regex = Regex::new(r"\{\{#path_for (?P<file>.+?)}}").unwrap();
115
116        let captures: Vec<Captures> = regex.captures_iter(&content).collect();
117
118        let mut processed_content = String::new();
119
120        let mut last_endpoint: usize = 0;
121
122        for capture in captures {
123            let full_match = capture.get(0).unwrap();
124
125            if let Some(file_name) = capture.name("file") {
126                let file_link = FileLink::from_string(file_name.as_str());
127                if let Some(path) = chapter_names.get(&file_link.name.to_lowercase()) {
128                    processed_content.push_str(&content[last_endpoint..full_match.start()]);
129                    last_endpoint = full_match.end();
130
131                    processed_content.push_str(options.site_path.as_str());
132                    processed_content.push_str(path.to_str().unwrap());
133                    if let Some(anchor) = file_link.anchor {
134                        processed_content.push_str("#");
135                        processed_content.push_str(anchor);
136                    }
137                } else {
138                    eprintln!("Error: Found request to replace link with '{}', but no chapter with that name found.", file_link.name.to_lowercase());
139                    return Err(ProcessorError::ChapterNotFound(file_link.name.to_lowercase()));
140                }
141            }
142        }
143
144        if content.len() > last_endpoint {
145            processed_content.push_str(&content[last_endpoint..content.len()]);
146        }
147
148        Ok(processed_content)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use std::collections::HashMap;
155    use std::path::PathBuf;
156    use crate::{PathProcessor, PathProcessorOptions};
157
158    #[test]
159    fn test_process_chapter_replaces_links_to_top_level() {
160        let content = "[foo]({{#path_for Foo}})";
161
162        let mut chapter_mapping: HashMap<String, PathBuf> = HashMap::new();
163        chapter_mapping.insert("foo".to_string(), PathBuf::from("something/Foo.md"));
164
165        let subject = PathProcessor;
166
167        let received_chapter = subject.process_chapter(&content, &chapter_mapping, &processor_options("/")).unwrap();
168
169        let expected_chapter = "[foo](/something/Foo.md)";
170
171        assert_eq!(received_chapter, expected_chapter.to_string());
172    }
173
174    #[test]
175    fn test_process_chapter_replaces_links_to_anchor() {
176        let content = "[foo]({{#path_for Foo#bar}})";
177
178        let mut chapter_mapping: HashMap<String, PathBuf> = HashMap::new();
179        chapter_mapping.insert("foo".to_string(), PathBuf::from("something/Foo.md"));
180
181        let subject = PathProcessor;
182
183        let received_chapter = subject.process_chapter(&content, &chapter_mapping, &processor_options("/root/")).unwrap();
184
185        let expected_chapter = "[foo](/root/something/Foo.md#bar)";
186
187        assert_eq!(received_chapter, expected_chapter.to_string());
188    }
189
190    fn processor_options(site_path: &str) -> PathProcessorOptions {
191        PathProcessorOptions {
192            site_path: site_path.to_string(),
193            strict_mode: false
194        }
195    }
196}