mdbook_reading_time/
lib.rs

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