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: ®ex::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}