mdbook_reading_time/
lib.rs

1use mdbook_preprocessor::{
2    book::{Book, BookItem, Chapter},
3    errors::Result,
4    Preprocessor, PreprocessorContext,
5};
6use unicode_segmentation::UnicodeSegmentation;
7
8#[derive(Default)]
9pub struct ReadingTime;
10
11impl ReadingTime {
12    pub fn new() -> ReadingTime {
13        ReadingTime
14    }
15}
16
17static WORDS_PER_MINUTE: usize = 200;
18
19impl Preprocessor for ReadingTime {
20    fn name(&self) -> &str {
21        "reading-time"
22    }
23
24    fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> {
25        let words_per_minute: usize = ctx
26            .config
27            .get::<usize>("preprocessor.reading-time.words-per-minute")
28            .ok()
29            .flatten()
30            .unwrap_or(WORDS_PER_MINUTE);
31
32        let mut book = book;
33        book.for_each_mut(|item: &mut BookItem| {
34            if let BookItem::Chapter(ref mut chapter) = *item {
35                let _ = handle_chapter(chapter, words_per_minute);
36            }
37        });
38
39        Ok(book)
40    }
41
42    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
43        Ok(renderer != "not-supported")
44    }
45}
46
47fn handle_chapter(chapter: &mut Chapter, words_per_minute: usize) -> Result<()> {
48    let content = chapter.content.as_str();
49    let word_count = content.unicode_words().count();
50    let reading_time = word_count / words_per_minute;
51    let minutes = if reading_time == 1 {
52        "minute"
53    } else {
54        "minutes"
55    };
56
57    chapter.content = chapter
58        .content
59        .replace("{{ #word_count }}", word_count.to_string().as_str())
60        .replace(
61            "{{ #reading_time }}",
62            &format!("{} {minutes}", reading_time.to_string().as_str()),
63        );
64    Ok(())
65}
66
67#[cfg(test)]
68mod test {
69    use super::*;
70
71    #[test]
72    fn reading_preprocessor_run() {
73        let input_json = r##"[
74                {
75                    "root": "/path/to/book",
76                    "config": {
77                        "book": {
78                            "authors": ["AUTHOR"],
79                            "language": "en",
80                            "src": "src",
81                            "title": "TITLE"
82                        },
83                        "preprocessor": {
84                            "reading-time": {}
85                        }
86                    },
87                    "renderer": "html",
88                    "mdbook_version": "0.5.1"
89                },
90                {
91                    "items": [
92                        {
93                            "Chapter": {
94                                "name": "Chapter 1",
95                                "content": "# Chapter 1\n {{ #word_count }}\n\n{{ #reading_time }}",
96                                "number": [1],
97                                "sub_items": [],
98                                "path": "chapter_1.md",
99                                "source_path": "chapter_1.md",
100                                "parent_names": []
101                            }
102                        }
103                    ],
104                    "__non_exhaustive": null
105                }
106            ]"##;
107        let input_json = input_json.as_bytes();
108
109        let (ctx, book) = mdbook_preprocessor::parse_input(input_json).unwrap();
110        let result = ReadingTime::new().run(&ctx, book);
111        assert!(result.is_ok());
112
113        let actual_book = result.unwrap();
114        let chapter = actual_book.iter().next().unwrap();
115
116        match chapter {
117            BookItem::Chapter(chapter) => {
118                assert_eq!(chapter.content, "# Chapter 1\n 4\n\n0 minutes");
119            }
120            _ => panic!("Expected a chapter"),
121        };
122    }
123}