mdbook_spec/
lib.rs

1use mdbook::book::{Book, Chapter};
2use mdbook::errors::Error;
3use mdbook::preprocess::{CmdPreprocessor, Preprocessor, PreprocessorContext};
4use mdbook::BookItem;
5use once_cell::sync::Lazy;
6use regex::{Captures, Regex};
7use semver::{Version, VersionReq};
8use std::collections::BTreeMap;
9use std::io;
10use std::path::PathBuf;
11
12mod std_links;
13
14/// The Regex for rules like `r[foo]`.
15static RULE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^r\[([^]]+)]$").unwrap());
16
17/// The Regex for the syntax for blockquotes that have a specific CSS class,
18/// like `> [!WARNING]`.
19static ADMONITION_RE: Lazy<Regex> = Lazy::new(|| {
20    Regex::new(r"(?m)^ *> \[!(?<admon>[^]]+)\]\n(?<blockquote>(?: *> .*\n)+)").unwrap()
21});
22
23pub fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
24    let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
25
26    let book_version = Version::parse(&ctx.mdbook_version)?;
27    let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?;
28
29    if !version_req.matches(&book_version) {
30        eprintln!(
31            "warning: The {} plugin was built against version {} of mdbook, \
32             but we're being called from version {}",
33            pre.name(),
34            mdbook::MDBOOK_VERSION,
35            ctx.mdbook_version
36        );
37    }
38
39    let processed_book = pre.run(&ctx, book)?;
40    serde_json::to_writer(io::stdout(), &processed_book)?;
41
42    Ok(())
43}
44
45pub struct Spec {
46    /// Whether or not warnings should be errors (set by SPEC_DENY_WARNINGS
47    /// environment variable).
48    deny_warnings: bool,
49}
50
51impl Spec {
52    pub fn new() -> Spec {
53        Spec {
54            deny_warnings: std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1"),
55        }
56    }
57
58    /// Converts lines that start with `r[…]` into a "rule" which has special
59    /// styling and can be linked to.
60    fn rule_definitions(
61        &self,
62        chapter: &Chapter,
63        found_rules: &mut BTreeMap<String, (PathBuf, PathBuf)>,
64    ) -> String {
65        let source_path = chapter.source_path.clone().unwrap_or_default();
66        let path = chapter.path.clone().unwrap_or_default();
67        RULE_RE
68            .replace_all(&chapter.content, |caps: &Captures| {
69                let rule_id = &caps[1];
70                if let Some((old, _)) =
71                    found_rules.insert(rule_id.to_string(), (source_path.clone(), path.clone()))
72                {
73                    let message = format!(
74                        "rule `{rule_id}` defined multiple times\n\
75                        First location: {old:?}\n\
76                        Second location: {source_path:?}"
77                    );
78                    if self.deny_warnings {
79                        panic!("error: {message}");
80                    } else {
81                        eprintln!("warning: {message}");
82                    }
83                }
84                format!(
85                    "<div class=\"rule\" id=\"{rule_id}\">\
86                     <a class=\"rule-link\" href=\"#{rule_id}\">[{rule_id}]</a>\
87                     </div>\n"
88                )
89            })
90            .to_string()
91    }
92
93    /// Generates link references to all rules on all pages, so you can easily
94    /// refer to rules anywhere in the book.
95    fn auto_link_references(
96        &self,
97        chapter: &Chapter,
98        found_rules: &BTreeMap<String, (PathBuf, PathBuf)>,
99    ) -> String {
100        let current_path = chapter.path.as_ref().unwrap().parent().unwrap();
101        let definitions: String = found_rules
102            .iter()
103            .map(|(rule_id, (_, path))| {
104                let relative = pathdiff::diff_paths(path, current_path).unwrap();
105                format!("[{rule_id}]: {}#{rule_id}\n", relative.display())
106            })
107            .collect();
108        format!(
109            "{}\n\
110            {definitions}",
111            chapter.content
112        )
113    }
114
115    /// Converts blockquotes with special headers into admonitions.
116    ///
117    /// The blockquote should look something like:
118    ///
119    /// ```markdown
120    /// > [!WARNING]
121    /// > ...
122    /// ```
123    ///
124    /// This will add a `<div class="warning">` around the blockquote so that
125    /// it can be styled differently. Any text between the brackets that can
126    /// be a CSS class is valid. The actual styling needs to be added in a CSS
127    /// file.
128    fn admonitions(&self, chapter: &Chapter) -> String {
129        ADMONITION_RE
130            .replace_all(&chapter.content, |caps: &Captures| {
131                let lower = caps["admon"].to_lowercase();
132                format!(
133                    "<div class=\"{lower}\">\n\n{}\n\n</div>\n",
134                    &caps["blockquote"]
135                )
136            })
137            .to_string()
138    }
139}
140
141impl Preprocessor for Spec {
142    fn name(&self) -> &str {
143        "nop-preprocessor"
144    }
145
146    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
147        let mut found_rules = BTreeMap::new();
148        book.for_each_mut(|item| {
149            let BookItem::Chapter(ch) = item else {
150                return;
151            };
152            if ch.is_draft_chapter() {
153                return;
154            }
155            ch.content = self.rule_definitions(&ch, &mut found_rules);
156            ch.content = self.admonitions(&ch);
157            ch.content = std_links::std_links(&ch);
158        });
159        // This is a separate pass because it relies on the modifications of
160        // the previous passes.
161        book.for_each_mut(|item| {
162            let BookItem::Chapter(ch) = item else {
163                return;
164            };
165            if ch.is_draft_chapter() {
166                return;
167            }
168            ch.content = self.auto_link_references(&ch, &found_rules);
169        });
170        Ok(book)
171    }
172}