mdbook_backlinks/
lib.rs

1use lazy_regex::{lazy_regex, Lazy};
2use mdbook::book::{Book, BookItem, Chapter};
3use mdbook::errors::Error;
4use mdbook::preprocess::{Preprocessor, PreprocessorContext};
5use pathdiff;
6use regex::Regex;
7use std::collections::HashMap;
8use std::path::PathBuf;
9use toml::value::Value;
10
11static WIKILINK_REGEX: Lazy<Regex> =
12    lazy_regex!(r"\[\[(?P<link>[^\]\|]+)(?:\|(?P<title>[^\]]+))?\]\]");
13
14pub struct Backlinks;
15
16impl Preprocessor for Backlinks {
17    fn name(&self) -> &str {
18        "backlinks"
19    }
20
21    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
22        // index maps each chapters source_path to its backlinks
23        let mut index = create_backlink_map(&book);
24
25        // Populate index backlinks
26        for item in book.iter() {
27            if let BookItem::Chapter(ch) = item {
28                // Skip if source_path is None (because nothing to link to)
29                if let Some(_) = &ch.source_path {
30                    // Loop over the internal links found in the chapter
31                    for link in find_links(&ch.content, &index) {
32                        // Push current chapter into Vec corresponding to the chapter it links to
33                        if let Some(backlinks) = index.get_mut(&link) {
34                            backlinks.push(ch.clone());
35                        }
36                    }
37                }
38            }
39        }
40
41        // Should probably clean up this, but why bother?...
42        let backlink_prefix = if let Some(header) = ctx.config.get("mdzk.backlinks-header")
43        {
44            if let Value::String(val) = header {
45                format!("\n\n---\n\n## {}\n\n", val)
46            } else {
47                eprintln!(
48                    "Warning: You must use a string value as the backlink header. Skipping..."
49                );
50                String::from("\n\n---\n\n")
51            }
52        } else {
53            String::from("\n\n")
54        };
55
56        // Add backlinks to each chapter
57        book.for_each_mut(|item| {
58            if let BookItem::Chapter(ch) = item {
59                if let Some(source_path) = &ch.source_path {
60                    if let Some(backlinks) = index.get(source_path) {
61                        if backlinks.len() >= 1 {
62                            ch.content += &backlink_prefix;
63                        };
64
65                        for backlink in backlinks.iter() {
66                            // This is really ugly, but the unwraps should be safe
67                            let dest = backlink.source_path.clone().unwrap();
68                            let diff_path =
69                                pathdiff::diff_paths(dest, source_path.parent().unwrap()).unwrap();
70                            ch.content += &format!(
71                                "> - [{}](<{}>)\n",
72                                backlink.name,
73                                escape_special_chars(diff_path.to_str().unwrap())
74                            );
75                        }
76                    }
77                }
78            }
79        });
80
81        Ok(book)
82    }
83}
84
85/// Finds all the links in content linking to chapters listed in index.
86fn find_links(content: &str, index: &HashMap<PathBuf, Vec<Chapter>>) -> Vec<PathBuf> {
87    let mut links: Vec<PathBuf> = Vec::new();
88
89    for cap in WIKILINK_REGEX.captures_iter(&content) {
90        if let Some(dest) = cap.get(1) {
91            let mut path = PathBuf::from(dest.as_str());
92            path.set_extension("md");
93            if index.contains_key(&path) {
94                links.push(path);
95            }
96        }
97    }
98    links.sort_unstable();
99    links.dedup();
100    links
101}
102
103/// Creates an index that maps all the book's chapter's source_path to an empty vector of backlinks
104fn create_backlink_map(book: &Book) -> HashMap<PathBuf, Vec<Chapter>> {
105    let mut map = HashMap::new();
106    for item in book.iter() {
107        if let BookItem::Chapter(ch) = item {
108            if let Some(source_path) = &ch.source_path {
109                map.insert(source_path.clone(), Vec::new());
110            }
111        }
112    }
113    map
114}
115
116fn escape_special_chars(text: &str) -> String {
117    text.replace(" ", "%20")
118        .replace("<", "%3C")
119        .replace(">", "%3E")
120        .replace('?', "%3F")
121}