mdbook_github_authors/
github_authors.rs

1use handlebars::{to_json, Handlebars};
2use mdbook::book::{Book, BookItem};
3use mdbook::preprocess::{Preprocessor, PreprocessorContext};
4use once_cell::sync::Lazy;
5use regex::{CaptureMatches, Captures, Regex};
6use serde::Serialize;
7use serde_json::value::Map;
8use std::include_str;
9
10const CONTRIBUTORS_TEMPLATE: &str = include_str!("./template/authors.hbs");
11
12#[derive(Default)]
13pub struct GithubAuthorsPreprocessor;
14
15/// A preprocessor for expanding "authors" helper.
16///
17/// NOTE: rather than expanding, this preprocessor adds a stylized Contributor section to
18/// the bottom of the Chapter, irrespective of where these author helpers are found in
19/// the raw markdown file.
20///
21/// Supported helpers are:
22///
23/// - `{{#author <github-username>}}` - Adds a single author to the Contributor section
24/// - `{{#authors <comma-separated-username-list>}}` - Adds listed authors to the
25///   Contributor section.
26impl GithubAuthorsPreprocessor {
27    pub(crate) const NAME: &'static str = "github-author";
28
29    /// Create a new `GithubAuthorsPreprocessor`.
30    pub fn new() -> Self {
31        GithubAuthorsPreprocessor
32    }
33}
34
35impl Preprocessor for GithubAuthorsPreprocessor {
36    fn name(&self) -> &str {
37        Self::NAME
38    }
39
40    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> anyhow::Result<Book> {
41        // This run method's implementation follows the implementation of
42        // mdbook::preprocess::links::LinkPreprocessor.run().
43        book.for_each_mut(|section: &mut BookItem| {
44            if let BookItem::Chapter(ref mut ch) = *section {
45                let (mut content, github_authors) = remove_all_links(&ch.content);
46
47                // get contributors html section
48                let mut data = Map::new();
49                if !github_authors.is_empty() {
50                    data.insert("authors".to_string(), to_json(github_authors));
51                }
52                let mut handlebars = Handlebars::new();
53
54                // register template from a file and assign a name to it
55                handlebars
56                    .register_template_string("contributors", CONTRIBUTORS_TEMPLATE)
57                    .unwrap();
58
59                if !data.is_empty() {
60                    let contributors_html = handlebars.render("contributors", &data).unwrap();
61                    content.push_str(contributors_html.as_str());
62                }
63
64                // mutate chapter content
65                ch.content = content;
66            }
67        });
68
69        Ok(book)
70    }
71}
72
73fn remove_all_links(s: &str) -> (String, Vec<GithubAuthor>) {
74    // This implementation follows closely to the implementation of
75    // mdbook::preprocess::links::replace_all.
76    // This removes all found author helpers and returns a Vec of `GithubAuthor`
77    // that are used later to render the Contributors section appended to the end
78    // of the Chapter's content.
79    let mut previous_end_index = 0;
80    let mut replaced = String::new();
81    let mut github_authors_vec = vec![];
82
83    for link in find_author_links(s) {
84        // remove the author link from the chapter content
85        replaced.push_str(&s[previous_end_index..link.start_index]);
86        replaced.push_str("");
87        previous_end_index = link.end_index;
88
89        // store the author usernames to create the contributors section with handlebars
90        let these_authors = match link.link_type {
91            AuthorLinkType::SingleAuthor(author) => {
92                vec![GithubAuthor {
93                    username: author.to_string(),
94                }]
95            }
96            AuthorLinkType::MultipleAuthors(author_list) => author_list
97                .split(",")
98                .map(|username| GithubAuthor {
99                    username: username.to_string(),
100                })
101                .collect(),
102        };
103
104        github_authors_vec.extend(these_authors);
105    }
106
107    replaced.push_str(&s[previous_end_index..]);
108    (replaced, github_authors_vec)
109}
110
111#[derive(PartialEq, Debug, Clone, Serialize)]
112pub struct GithubAuthor {
113    username: String,
114}
115
116#[derive(PartialEq, Debug, Clone)]
117enum AuthorLinkType<'a> {
118    SingleAuthor(&'a str),
119    MultipleAuthors(&'a str),
120}
121
122#[derive(PartialEq, Debug, Clone)]
123struct AuthorLink<'a> {
124    start_index: usize,
125    end_index: usize,
126    link_type: AuthorLinkType<'a>,
127    link_text: &'a str,
128}
129
130impl<'a> AuthorLink<'a> {
131    fn from_capture(cap: Captures<'a>) -> Option<AuthorLink<'a>> {
132        let link_type = match (cap.get(0), cap.get(1), cap.get(2)) {
133            (_, Some(typ), Some(author))
134                if ((typ.as_str() == "author") && (!author.as_str().trim().is_empty())) =>
135            {
136                Some(AuthorLinkType::SingleAuthor(author.as_str().trim()))
137            }
138            (_, Some(typ), Some(authors_list))
139                if ((typ.as_str() == "authors") && (!authors_list.as_str().trim().is_empty())) =>
140            {
141                Some(AuthorLinkType::MultipleAuthors(
142                    authors_list.as_str().trim(),
143                ))
144            }
145            _ => None,
146        };
147
148        link_type.and_then(|lnk_type| {
149            cap.get(0).map(|mat| AuthorLink {
150                start_index: mat.start(),
151                end_index: mat.end(),
152                link_type: lnk_type,
153                link_text: mat.as_str(),
154            })
155        })
156    }
157}
158
159struct AuthorLinkIter<'a>(CaptureMatches<'a, 'a>);
160
161impl<'a> Iterator for AuthorLinkIter<'a> {
162    type Item = AuthorLink<'a>;
163    fn next(&mut self) -> Option<AuthorLink<'a>> {
164        for cap in &mut self.0 {
165            if let Some(inc) = AuthorLink::from_capture(cap) {
166                return Some(inc);
167            }
168        }
169        None
170    }
171}
172
173fn find_author_links(contents: &str) -> AuthorLinkIter<'_> {
174    // lazily compute following regex
175    // r"\\\{\{#.*\}\}|\{\{#([a-zA-Z0-9]+)\s*([^}]+)\}\}")?;
176    static RE: Lazy<Regex> = Lazy::new(|| {
177        Regex::new(
178            r"(?x)              # insignificant whitespace mode
179        \\\{\{\#.*\}\}      # match escaped link
180        |                   # or
181        \{\{\s*             # link opening parens and whitespace
182        \#([a-zA-Z0-9_]+)   # link type
183        \s+                 # separating whitespace
184        ([^}]+)             # link target path and space separated properties
185        \}\}                # link closing parens",
186        )
187        .unwrap()
188    });
189
190    AuthorLinkIter(RE.captures_iter(contents))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use anyhow::Result;
197    use rstest::*;
198
199    #[fixture]
200    fn simple_book_content() -> String {
201        "Some random text with and more text ... {{#author foo}} {{#authors bar,baz  }}".to_string()
202    }
203
204    #[rstest]
205    fn test_find_links_no_author_links() -> Result<()> {
206        let s = "Some random text without link...";
207        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
208        Ok(())
209    }
210
211    #[rstest]
212    fn test_find_links_partial_link() -> Result<()> {
213        let s = "Some random text with {{#playground...";
214        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
215        let s = "Some random text with {{#include...";
216        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
217        let s = "Some random text with \\{{#include...";
218        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
219        Ok(())
220    }
221
222    #[rstest]
223    fn test_find_links_empty_link() -> Result<()> {
224        let s = "Some random text with {{#author  }} and {{#authors   }} {{}} {{#}}...";
225        println!("{:?}", find_author_links(s).collect::<Vec<_>>());
226        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
227        Ok(())
228    }
229
230    #[rstest]
231    fn test_find_links_unknown_link_type() -> Result<()> {
232        let s = "Some random text with {{#my_author ar.rs}} and {{#auth}} {{baz}} {{#bar}}...";
233        assert!(find_author_links(s).collect::<Vec<_>>() == vec![]);
234        Ok(())
235    }
236
237    #[rstest]
238    fn test_find_links_simple_author_links(simple_book_content: String) -> Result<()> {
239        let res = find_author_links(&simple_book_content[..]).collect::<Vec<_>>();
240        println!("\nOUTPUT: {res:?}\n");
241
242        assert_eq!(
243            res,
244            vec![
245                AuthorLink {
246                    start_index: 40,
247                    end_index: 55,
248                    link_type: AuthorLinkType::SingleAuthor("foo"),
249                    link_text: "{{#author foo}}",
250                },
251                AuthorLink {
252                    start_index: 56,
253                    end_index: 78,
254                    link_type: AuthorLinkType::MultipleAuthors("bar,baz"),
255                    link_text: "{{#authors bar,baz  }}",
256                },
257            ]
258        );
259        Ok(())
260    }
261
262    #[rstest]
263    fn test_remove_all_links(simple_book_content: String) -> Result<()> {
264        let (c, authors) = remove_all_links(&simple_book_content[..]);
265
266        assert_eq!(c, "Some random text with and more text ...  ");
267        assert_eq!(
268            authors,
269            vec![
270                GithubAuthor {
271                    username: "foo".to_string()
272                },
273                GithubAuthor {
274                    username: "bar".to_string()
275                },
276                GithubAuthor {
277                    username: "baz".to_string()
278                }
279            ]
280        );
281        Ok(())
282    }
283}