mdbook_reading_time/
lib.rs1use 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}