mdbook_chapter_path/
lib.rs1use 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 ChapterNotFound(String),
16 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 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}