mdbook_cms/
cms.rs

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/// A preprocessor for reading YAML front matter from a markdown file.
40/// - `author` - For setting the author meta tag.
41/// - `title` - For overwritting the title tag.
42/// - `description` - For setting the description meta tag.
43/// - `keywords` - For setting the keywords meta tag.
44/// - `dir` - For setting the dir meta tag.
45#[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    /// Create a new `MetadataPreprocessor`.
61    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        // In testing we want to tell the preprocessor to blow up by setting a
75        // particular config value
76        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                                // 暂时不用
102                                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        // lazily compute following regex
129        // r"\A-{3,}\n(?P<metadata>.*?)^{3,}\n"
130        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            // safe to unwrap as we know there is a match
142            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        // println!("{:?}", entry);
370        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}