mdbook_alerts/
lib.rs

1use mdbook::book::{Book, BookItem, Chapter};
2use mdbook::errors::Error;
3use mdbook::preprocess::PreprocessorContext;
4use once_cell::sync::Lazy;
5use regex::Regex;
6use rust_embed::RustEmbed;
7
8#[derive(RustEmbed)]
9#[folder = "assets/"]
10struct Asset;
11
12pub struct Preprocessor;
13
14impl mdbook::preprocess::Preprocessor for Preprocessor {
15    fn name(&self) -> &str {
16        "alerts"
17    }
18
19    fn supports_renderer(&self, renderer: &str) -> bool {
20        renderer == "html"
21    }
22
23    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
24        let mut error: Option<Error> = None;
25        book.for_each_mut(|item: &mut BookItem| {
26            if error.is_some() {
27                return;
28            }
29            if let BookItem::Chapter(ref mut chapter) = *item {
30                if let Err(err) = handle_chapter(chapter) {
31                    error = Some(err)
32                }
33            }
34        });
35        error.map_or(Ok(book), Err)
36    }
37}
38
39fn handle_chapter(chapter: &mut Chapter) -> Result<(), Error> {
40    chapter.content = inject_stylesheet(&chapter.content)?;
41    chapter.content = render_alerts(&chapter.content)?;
42    Ok(())
43}
44
45fn inject_stylesheet(content: &str) -> Result<String, Error> {
46    let style = Asset::get("style.css").expect("style.css not found in assets");
47    let style = std::str::from_utf8(style.data.as_ref())?;
48    Ok(format!("<style>\n{style}\n</style>\n{content}"))
49}
50
51fn render_alerts(content: &str) -> Result<String, Error> {
52    static RE: Lazy<Regex> = Lazy::new(|| {
53        Regex::new(r"(?m)^> \[!(?P<kind>[^\]]+)\]\s*$(?P<body>(?:\n>.*)*)")
54            .expect("failed to parse regex")
55    });
56    let alerts = Asset::get("alerts.tmpl").expect("alerts.tmpl not found in assets");
57    let alerts = std::str::from_utf8(alerts.data.as_ref())?;
58    let alerts = alerts.replace("\r\n", "\n");
59    let newline = find_newline(content);
60    let content = content.replace(&newline, "\n");
61    let content = content.as_str();
62    let content = RE.replace_all(content, |caps: &regex::Captures| {
63        let kind = caps
64            .name("kind")
65            .expect("kind not found in regex")
66            .as_str()
67            .to_lowercase();
68        let body = caps
69            .name("body")
70            .expect("body not found in regex")
71            .as_str()
72            .replace("\n>\n", "\n\n")
73            .replace("\n> ", "\n");
74        alerts.replace("{kind}", &kind).replace("{body}", &body)
75    });
76    Ok(content.replace("\n", newline))
77}
78
79fn find_newline(content: &str) -> &'static str {
80    let mut cr = 0;
81    let mut lf = 0;
82    content.chars().for_each(|c| match c {
83        '\r' => cr += 1,
84        '\n' => lf += 1,
85        _ => {}
86    });
87    return if cr == lf { "\r\n" } else { "\n" };
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use indoc::indoc;
94    use insta::assert_debug_snapshot;
95    use insta::assert_snapshot;
96
97    #[test]
98    fn test_inject_stylesheet_includes_css() {
99        let content = "Hello, world!";
100        let result = inject_stylesheet(content).unwrap();
101        assert!(result.contains("<style>"));
102        assert!(result.contains(".mdbook-alerts"));
103        assert!(result.contains("</style>"));
104        assert!(result.contains("Hello, world!"));
105    }
106
107    #[test]
108    fn test_render_alerts_basic_alert() {
109        let input = "> [!note]\n> This is a note.";
110        let output = render_alerts(input).unwrap();
111        assert!(output.contains("note"));
112        assert!(output.contains("This is a note."));
113        assert_eq!(
114            output,
115            indoc! {r#"
116            <div class="mdbook-alerts mdbook-alerts-note">
117            <p class="mdbook-alerts-title">
118              <span class="mdbook-alerts-icon"></span>
119              note
120            </p>
121            
122            
123            This is a note.
124            
125            </div>
126            "#}
127        );
128    }
129
130    #[test]
131    fn test_render_alerts_multiple_alerts() {
132        let input = "> [!warning]\n> Warning 1.\n\n> [!tip]\n> Tip 2.";
133        let output = render_alerts(input).unwrap();
134        assert!(output.contains("warning"));
135        assert!(output.contains("Warning 1."));
136        assert!(output.contains("tip"));
137        assert!(output.contains("Tip 2."));
138        assert_eq!(
139            output,
140            indoc! {r#"
141            <div class="mdbook-alerts mdbook-alerts-warning">
142            <p class="mdbook-alerts-title">
143              <span class="mdbook-alerts-icon"></span>
144              warning
145            </p>
146
147
148            Warning 1.
149
150            </div>
151
152
153            <div class="mdbook-alerts mdbook-alerts-tip">
154            <p class="mdbook-alerts-title">
155              <span class="mdbook-alerts-icon"></span>
156              tip
157            </p>
158
159
160            Tip 2.
161
162            </div>
163            "#}
164        );
165    }
166
167    #[test]
168    fn test_render_alerts() {
169        let content = indoc! {r#"
170        This should be a paragraph.
171
172        > This should be a blockquote.
173        > This should be in a previous blockquote.
174
175        > [!NOTE]
176        > This should be alert.
177        > This should be in a previous alert.
178
179        This should be a paragraph.
180        "#};
181        let result = render_alerts(content).unwrap();
182        assert_debug_snapshot!(result);
183        assert_snapshot!(result);
184    }
185
186    #[test]
187    fn test_render_alerts_with_crlf() {
188        let content = indoc! {r#"
189        This should be a paragraph.
190
191        > This should be a blockquote.
192        > This should be in a previous blockquote.
193
194        > [!NOTE]
195        > This should be alert.
196        > This should be in a previous alert.
197
198        This should be a paragraph.
199        "#};
200        let content = content.replace("\n", "\r\n");
201        let result = render_alerts(content.as_str()).unwrap();
202        assert_debug_snapshot!(result);
203        assert_snapshot!(result);
204    }
205}