mdbook_github_authors/
github_authors.rs1use 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
15impl GithubAuthorsPreprocessor {
27 pub(crate) const NAME: &'static str = "github-author";
28
29 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 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 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 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 ch.content = content;
66 }
67 });
68
69 Ok(book)
70 }
71}
72
73fn remove_all_links(s: &str) -> (String, Vec<GithubAuthor>) {
74 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 replaced.push_str(&s[previous_end_index..link.start_index]);
86 replaced.push_str("");
87 previous_end_index = link.end_index;
88
89 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 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}