1use indexmap::IndexMap;
2use md5::{Digest, Md5};
3use mdbook::book::{Book, BookItem};
4use mdbook::errors::*;
5use mdbook::preprocess::{Preprocessor, PreprocessorContext};
6use mdbook::MDBook;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::fs;
11use std::fs::DirEntry;
12use std::io::prelude::*;
13use std::io::{BufReader, BufWriter};
14use std::ops::Range;
15use std::path::Path;
16
17const README_FILE: &str = "README.md";
18const SUMMARY_FILE: &str = "SUMMARY.md";
19
20const TITLE_WAY: &str = "title";
21
22#[derive(Debug)]
23pub struct MdFile {
24 pub meta: Meta,
25 pub file: String,
26 pub path: String,
27}
28
29#[derive(Debug)]
30pub struct MdGroup {
31 pub name: String,
32 pub path: String,
33 pub has_readme: bool,
34 pub md_list: Vec<MdFile>,
35 pub group_list: Vec<MdGroup>,
36 pub group_map: IndexMap<String, Vec<MdFile>>,
37}
38
39#[derive(Debug, Default, Serialize, Deserialize)]
46pub struct Meta {
47 pub section: Option<String>,
48 pub title: Option<String>,
49 pub author: Option<String>,
50 pub description: Option<String>,
51 pub keywords: Option<Vec<String>>,
52}
53
54#[derive(Default)]
55pub struct CMSPreprocessor;
56
57impl CMSPreprocessor {
58 pub(crate) const NAME: &'static str = "cms";
59
60 pub fn new() -> Self {
62 CMSPreprocessor
63 }
64}
65
66impl Preprocessor for CMSPreprocessor {
67 fn name(&self) -> &str {
68 Self::NAME
69 }
70
71 fn run(&self, ctx: &PreprocessorContext, mut _book: Book) -> Result<Book> {
72 let mut title_way = "filename";
73
74 if let Some(nop_cfg) = ctx.config.get_preprocessor(self.name()) {
77 if nop_cfg.contains_key("blow-up") {
78 anyhow::bail!("Boom!!1!");
79 }
80 if nop_cfg.contains_key(TITLE_WAY) {
81 let v = nop_cfg.get(TITLE_WAY).unwrap();
82 title_way = v.as_str().unwrap_or("filename");
83 }
84 }
85
86 let source_dir = ctx
87 .root
88 .join(&ctx.config.book.src)
89 .to_str()
90 .unwrap()
91 .to_string();
92
93 gen_summary(&source_dir, title_way);
94
95 match MDBook::load(&ctx.root) {
96 Ok(mut mdbook) => {
97 mdbook.book.for_each_mut(|section: &mut BookItem| {
98 if let BookItem::Chapter(ref mut ch) = *section {
99 if let Some(m) = Match::find_metadata(&ch.content) {
100 if let Ok(meta) = serde_yaml::from_str(&ch.content[m.range]) {
101 let _meta: Value = meta;
103 ch.content = String::from(&ch.content[m.end..]);
104 };
105 }
106 }
107 });
108 Ok(mdbook.book)
109 }
110 Err(e) => {
111 panic!("{}", e);
112 }
113 }
114 }
115
116 fn supports_renderer(&self, renderer: &str) -> bool {
117 renderer != "not-supported"
118 }
119}
120
121pub(crate) struct Match {
122 pub(crate) range: Range<usize>,
123 pub(crate) end: usize,
124}
125
126impl Match {
127 pub(crate) fn find_metadata(contents: &str) -> Option<Match> {
128 lazy_static::lazy_static! {
131 static ref RE: Regex = Regex::new(
132 r"(?xms) # insignificant whitespace mode and multiline
133 \A-{3,}\n # match a horizontal rule at the start of the content
134 (?P<metadata>.*?) # name the match between horizontal rules metadata
135 ^-{3,}\n # match a horizontal rule
136 "
137 )
138 .unwrap();
139 };
140 if let Some(mat) = RE.captures(contents) {
141 let metadata = mat.name("metadata").unwrap();
143 Some(Match {
144 range: metadata.start()..metadata.end(),
145 end: mat.get(0).unwrap().end(),
146 })
147 } else {
148 None
149 }
150 }
151}
152
153fn md5(buf: &str) -> String {
154 let mut hasher = Md5::new();
155 hasher.update(buf.as_bytes());
156 let f = hasher.finalize();
157 let md5_vec = f.as_slice();
158 hex::encode_upper(md5_vec)
159}
160
161pub fn gen_summary(source_dir: &str, title_way: &str) {
162 let mut source_dir = source_dir.to_string();
163 if !source_dir.ends_with('/') {
164 source_dir.push('/')
165 }
166 let group = walk_dir(&source_dir, title_way);
167 let lines = gen_summary_lines(&source_dir, &group, title_way);
168 let buff: String = lines.join("\n");
169
170 let new_md5_string = md5(&buff);
171
172 let summary_file = std::fs::OpenOptions::new()
173 .write(true)
174 .read(true)
175 .create(true)
176 .open(source_dir.clone() + "/" + SUMMARY_FILE)
177 .unwrap();
178
179 let mut old_summary_file_content = String::new();
180 let mut summary_file_reader = BufReader::new(summary_file);
181 summary_file_reader
182 .read_to_string(&mut old_summary_file_content)
183 .unwrap();
184
185 let old_md5_string = md5(&old_summary_file_content);
186
187 if new_md5_string == old_md5_string {
188 return;
189 }
190
191 let summary_file = std::fs::OpenOptions::new()
192 .write(true)
193 .read(true)
194 .create(true)
195 .truncate(true)
196 .open(source_dir + "/" + SUMMARY_FILE)
197 .unwrap();
198 let mut summary_file_writer = BufWriter::new(summary_file);
199 summary_file_writer.write_all(buff.as_bytes()).unwrap();
200}
201
202fn count(s: &str) -> usize {
203 s.split('/').count()
204}
205
206fn gen_summary_lines(root_dir: &str, group: &MdGroup, title_way: &str) -> Vec<String> {
207 let mut lines: Vec<String> = vec![];
208
209 let path = group.path.replace(root_dir, "");
210 let cnt = count(&path);
211
212 let buff_spaces = " ".repeat(4 * (cnt - 1));
213 let mut name = group.name.clone();
214
215 let buff_link: String;
216 if name == "src" {
217 name = String::from("Welcome");
218 }
219
220 if path.is_empty() {
221 lines.push(String::from("# SUMMARY"));
222 buff_link = String::new();
223 } else {
224 buff_link = format!("{}* [{}]()", buff_spaces, name);
225 }
226
227 if buff_spaces.is_empty() {
228 lines.push(String::from("\n"));
229 if name != "Welcome" {
230 lines.push(String::from("----"));
231 }
232 }
233
234 lines.push(buff_link);
235
236 for md in &group.md_list {
237 let path = md.path.replace(root_dir, "");
238 if path == SUMMARY_FILE {
239 continue;
240 }
241 if path.ends_with(README_FILE) {
242 continue;
243 }
244
245 let cnt = count(&path);
246 let buff_spaces = " ".repeat(4 * (cnt - 1));
247
248 let buff_link: String;
249
250 let meta = &md.meta;
251 let title = match meta.title.as_ref() {
252 None => "",
253 Some(title) => title,
254 };
255
256 if title_way != "filename" && !title.is_empty() {
257 buff_link = format!("{}* [{}]({})", buff_spaces, title, path);
258 } else {
259 buff_link = format!("{}* [{}]({})", buff_spaces, md.file, path);
260 }
261
262 lines.push(buff_link);
263 }
264
265 for (parent, ml) in &group.group_map {
266 lines.push(format!("* [{}]()", parent));
267 for md in ml {
268 let path = md.path.replace(root_dir, "");
269 if path == SUMMARY_FILE {
270 continue;
271 }
272 if path.ends_with(README_FILE) {
273 continue;
274 }
275 let buff_spaces = " ".repeat(4);
276
277 let buff_link: String;
278
279 let meta = &md.meta;
280 let title = match meta.title.as_ref() {
281 None => "",
282 Some(title) => title,
283 };
284 if title_way != "filename" && !title.is_empty() {
285 buff_link = format!("{}* [{}]({})", buff_spaces, title, path);
286 } else {
287 buff_link = format!("{}* [{}]({})", buff_spaces, md.file, path);
288 }
289
290 lines.push(buff_link);
291 }
292 }
293
294 for group in &group.group_list {
295 let mut line = gen_summary_lines(root_dir, group, title_way);
296 lines.append(&mut line);
297 }
298
299 lines
300}
301
302fn get_meta(entry: &DirEntry, title_way: &str) -> Meta {
303 let md_file = std::fs::File::open(entry.path().to_str().unwrap()).unwrap();
304 let mut md_file_content = String::new();
305 let mut md_file_reader = BufReader::new(md_file);
306 md_file_reader.read_to_string(&mut md_file_content).unwrap();
307
308 match title_way {
309 "first-line" => {
310 let lines = md_file_content.split('\n');
311
312 let mut title: String = "".to_string();
313 let mut first_h1_line = "";
314 for line in lines {
315 if line.starts_with("# ") {
316 first_h1_line = line.trim_matches('#').trim();
317 break;
318 }
319 }
320
321 if first_h1_line.is_empty() {
322 title = first_h1_line.to_string();
323 }
324
325 Meta {
326 section: None,
327 title: Some(title),
328 author: None,
329 description: None,
330 keywords: None,
331 }
332 }
333 "meta" => {
334 if let Some(m) = Match::find_metadata(&md_file_content) {
335 let meta_info = &md_file_content[m.range];
336
337 match serde_yaml::from_str(meta_info) {
338 Ok(meta) => meta,
339 Err(_e) => Meta::default(),
340 }
341 } else {
342 Meta::default()
343 }
344 }
345 _ => Meta::default(),
346 }
347}
348
349fn walk_dir(dir: &str, title_way: &str) -> MdGroup {
350 let read_dir = fs::read_dir(dir).unwrap();
351 let name = Path::new(dir)
352 .file_name()
353 .unwrap()
354 .to_owned()
355 .to_str()
356 .unwrap()
357 .to_string();
358 let mut group = MdGroup {
359 name,
360 path: dir.to_string(),
361 has_readme: false,
362 group_list: vec![],
363 md_list: vec![],
364 group_map: Default::default(),
365 };
366
367 for entry in read_dir {
368 let entry = entry.unwrap();
369 if entry.file_type().unwrap().is_dir() {
371 let g = walk_dir(entry.path().to_str().unwrap(), title_way);
372 if g.has_readme {
373 group.group_list.push(g);
374 }
375 continue;
376 }
377 let file_name = entry.file_name();
378 let file_name = file_name.to_str().unwrap().to_string();
379 if file_name == README_FILE {
380 group.has_readme = true;
381 }
382 let arr: Vec<&str> = file_name.split('.').collect();
383 if arr.len() < 2 {
384 continue;
385 }
386 let file_name = arr[0];
387 let file_ext = arr[1];
388 if file_ext.to_lowercase() != "md" {
389 continue;
390 }
391
392 let meta = get_meta(&entry, title_way);
393
394 match &meta.section {
395 None => {
396 let md = MdFile {
397 meta,
398 file: file_name.to_string(),
399 path: entry.path().to_str().unwrap().to_string(),
400 };
401 group.md_list.push(md);
402 }
403 Some(meta_dir) => {
404 let meta_dir = meta_dir.clone();
405 let md = MdFile {
406 meta,
407 file: file_name.to_string(),
408 path: entry.path().to_str().unwrap().to_string(),
409 };
410 (*group.group_map.entry(meta_dir.clone()).or_default()).push(md);
411 }
412 }
413 }
414
415 group
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_find_metadata_not_at_start() {
424 let s = "\
425 content\n\
426 ---
427 author: \"Adam\"
428 title: \"Blog Post #1\"
429 keywords:
430 : \"rust\"
431 : \"blog\"
432 date: \"2021/02/15\"
433 modified: \"2021/02/16\"\n\
434 ---
435 content
436 ";
437 if let Some(_) = Match::find_metadata(s) {
438 panic!()
439 }
440 }
441
442 #[test]
443 fn test_find_metadata_at_start() {
444 let s = "\
445 ---
446 author: \"Adam\"
447 title: \"Blog Post #1\"
448 keywords:
449 - \"rust\"
450 - \"blog\"
451 date: \"2021/02/15\"
452 description: \"My rust blog.\"
453 modified: \"2021/02/16\"\n\
454 ---\n\
455 content
456 ";
457 if let None = Match::find_metadata(s) {
458 panic!()
459 }
460 }
461
462 #[test]
463 fn test_find_metadata_partial_metadata() {
464 let s = "\
465 ---
466 author: \"Adam\n\
467 content
468 ";
469 if let Some(_) = Match::find_metadata(s) {
470 panic!()
471 }
472 }
473
474 #[test]
475 fn test_find_metadata_not_metadata() {
476 type Map = serde_json::Map<String, serde_json::Value>;
477 let s = "\
478 ---
479 This is just standard content that happens to start with a line break
480 and has a second line break in the text.\n\
481 ---
482 followed by more content
483 ";
484 if let Some(m) = Match::find_metadata(s) {
485 if let Ok(_) = serde_yaml::from_str::<Map>(&s[m.range]) {
486 panic!()
487 }
488 }
489 }
490}