mdbook_section_validator/
lib.rs1mod link_formatter;
2pub mod issue_validator;
3
4use regex::{Regex, Captures};
5
6use mdbook::book::{Book, BookItem};
7use mdbook::errors::Error;
8use mdbook::preprocess::{Preprocessor, PreprocessorContext};
9use url::Url;
10use crate::link_formatter::LinkFormatter;
11use crate::issue_validator::{IssueValidator, issue_from_url, ValidationResult};
12
13pub struct ValidatorProcessorOptions {
14 hide_invalid: bool,
15 invalid_message: String
16}
17
18#[derive(Debug, Eq, PartialEq)]
19enum ValidationSection {
20 NonValidationSection(String),
21 ValidationSection(Vec<Url>, String),
22}
23
24pub struct ValidatorProcessor {
25 pub validator: Box<dyn IssueValidator>
26}
27
28impl Preprocessor for ValidatorProcessor {
29 fn name(&self) -> &str { "section-validator" }
30
31 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
32 let options = self.build_options(ctx);
33
34 book.for_each_mut(|item| {
35 if let BookItem::Chapter(chapter) = item {
36 chapter.content =
37 self.process_chapter(&chapter.content, &options)
38 }
39 });
40 Ok(book)
41 }
42
43 fn supports_renderer(&self, renderer: &str) -> bool { renderer == "html" }
44}
45
46impl ValidatorProcessor {
47 fn build_options(&self, ctx: &PreprocessorContext) -> ValidatorProcessorOptions {
48 let mut options = ValidatorProcessorOptions {
49 hide_invalid: true,
50 invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
51 };
52
53 if let Some(config) = ctx.config.get_preprocessor("section-validator") {
54 if let Some(toml::value::Value::Boolean(hide_closed)) = config.get("hide_invalid") {
55 options.hide_invalid = *hide_closed;
56 }
57 if let Some(toml::value::Value::String(message)) = config.get("invalid_message") {
58 options.invalid_message = message.to_string();
59 }
60 }
61
62 options
63 }
64
65 fn process_chapter(
66 &self,
67 raw_content: &str,
68 options: &ValidatorProcessorOptions
69 ) -> String {
70 let mut content = String::new();
71 for section in ValidatorProcessor::validation_sections(raw_content) {
72 match section {
73 ValidationSection::NonValidationSection(text) => {
74 content.push_str(&text);
75 },
76 ValidationSection::ValidationSection(links, text) => {
77 let validation_result = self.is_section_valid(&links);
78 if options.hide_invalid && validation_result == ValidationResult::NoLongerValid {
79 continue;
80 }
81 content.push_str(&*format!("<div class=\"validated-content\" links=\"{}\">\n\n", ValidatorProcessor::links_joined(&links)));
82 if validation_result == ValidationResult::NoLongerValid {
83 content.push_str(&*options.invalid_message);
84 } else {
85 let mut is_or_are = "is";
86 if links.len() != 1 {
87 is_or_are = "are";
88 }
89 content.push_str(&*format!("⚠️ This is only valid while {} {} open", LinkFormatter::markdown_many(&links), is_or_are));
90 }
91 content.push_str(&text);
92 content.push_str("\n</div>");
93 }
94 }
95 }
96 content
97 }
98
99 fn validation_sections(raw_content: &str) -> Vec<ValidationSection> {
100 let section_regex = Regex::new(r"(?m)^!!!(.+)$(?s)(.+?)(?-s)^!!!$").unwrap();
101
102 let captures: Vec<Captures> = section_regex.captures_iter(&raw_content).collect();
103 let mut sections: Vec<ValidationSection> = Vec::new();
104
105 if captures.is_empty() {
106 return vec!(ValidationSection::NonValidationSection(raw_content.to_string()));
107 }
108
109 let mut last_endpoint: usize = 0;
110 for capture in captures {
111 let mat = capture.get(0).unwrap();
112 let start = mat.start();
113
114 if start - last_endpoint != 0 {
115 sections.push(ValidationSection::NonValidationSection(raw_content[last_endpoint..start].to_string()));
116 }
117
118 last_endpoint = mat.end();
119
120 sections.push(ValidationSection::ValidationSection(
121 ValidatorProcessor::links_to_check(capture.get(1).unwrap().as_str()),
122 capture.get(2).unwrap().as_str().to_string()
123 ))
124 }
125
126
127 if raw_content.len() > last_endpoint {
128 sections.push(ValidationSection::NonValidationSection(raw_content[last_endpoint..raw_content.len()].to_string()));
129 }
130
131 return sections;
132 }
133
134 fn links_to_check(links: &str) -> Vec<Url> {
135 links.split(",").map(|text| Url::parse(text).unwrap()).collect()
136 }
137
138 fn links_joined(links: &Vec<Url>) -> String {
139 let links_strs: Vec<String> = links.into_iter().map(|url| url.as_str().to_string()).collect();
140 links_strs.join(",")
141 }
142
143 fn is_section_valid(&self, links: &Vec<Url>) ->ValidationResult {
144 links.into_iter()
145 .map(|u| issue_from_url(u))
146 .map(|issue| self.validator.validate(&issue))
147 .reduce(|a, b|
148 if a == ValidationResult::StillValid && b == ValidationResult::StillValid { a }
149 else { ValidationResult::NoLongerValid }
150 ).unwrap()
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::IssueValidator;
157 use super::ValidatorProcessor;
158 use super::ValidationSection;
159 use url::Url;
160 use crate::issue_validator::{Issue, ValidationResult};
161 use crate::ValidatorProcessorOptions;
162
163 #[test]
164 fn test_validation_sections_single_link() {
165 let content = "whatever
166!!!https://github.com/example/example/issues/1
167
168some content to be conditionally included.
169
170!!!
171
172other content";
173
174 let sections: Vec<ValidationSection> = ValidatorProcessor::validation_sections(&content);
175
176 assert_eq!(sections.len(), 3);
177 assert_eq!(sections.get(0).unwrap(), &ValidationSection::NonValidationSection("whatever\n".to_string()));
178 assert_eq!(
179 sections.get(1).unwrap(),
180 &ValidationSection::ValidationSection(
181 vec![Url::parse("https://github.com/example/example/issues/1").unwrap()],
182 "\n\nsome content to be conditionally included.\n\n".to_string()
183 )
184 );
185 assert_eq!(sections.get(2).unwrap(), &ValidationSection::NonValidationSection("\n\nother content".to_string()));
186 }
187
188 #[test]
189 fn test_validation_sections_multiple() {
190 let content = "!!!https://github.com/example/example/issues/1
191
192some content to be conditionally included.
193
194!!!
195
196other content
197
198!!!https://github.com/example/example/issues/1,https://github.com/example/example/issues/2
199
200other content to be conditionally included.
201
202!!!";
203
204 let sections: Vec<ValidationSection> = ValidatorProcessor::validation_sections(&content);
205
206 assert_eq!(sections.len(), 3);
207 assert_eq!(
208 sections.get(0).unwrap(),
209 &ValidationSection::ValidationSection(
210 vec![Url::parse("https://github.com/example/example/issues/1").unwrap()],
211 "\n\nsome content to be conditionally included.\n\n".to_string()
212 )
213 );
214 assert_eq!(sections.get(1).unwrap(), &ValidationSection::NonValidationSection("\n\nother content\n\n".to_string()));
215 assert_eq!(
216 sections.get(2).unwrap(),
217 &ValidationSection::ValidationSection(
218 vec![
219 Url::parse("https://github.com/example/example/issues/1").unwrap(),
220 Url::parse("https://github.com/example/example/issues/2").unwrap()
221 ],
222 "\n\nother content to be conditionally included.\n\n".to_string()
223 )
224 );
225 }
226
227 #[test]
228 fn test_content_all_valid_still_included_with_warning() {
229 let content = "whatever
230!!!https://github.com/example/example/issues/1
231
232some content to be conditionally included.
233
234!!!
235
236other content
237 ";
238
239 let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::AllValid };
240
241 let processor = ValidatorProcessor { validator: Box::new(validator) };
242
243 let options = ValidatorProcessorOptions { hide_invalid: true, invalid_message: "".to_string() };
244
245 let received_chapter = processor.process_chapter(
246 content,
247 &options
248 );
249
250 let expected_chapter = "whatever
251<div class=\"validated-content\" links=\"https://github.com/example/example/issues/1\">
252
253⚠️ This is only valid while [example/example#1](https://github.com/example/example/issues/1) is open ⚠️
254
255some content to be conditionally included.
256
257
258</div>
259
260other content
261 ";
262 assert_eq!(received_chapter, expected_chapter.to_string());
263 }
264
265 #[test]
266 fn tset_content_none_valid_content_not_included() {
267 let content = "whatever
268!!!https://github.com/example/example/issues/1
269
270some content to be conditionally included.
271
272!!!
273
274other content
275 ";
276
277 let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::NoneValid };
278
279 let processor = ValidatorProcessor { validator: Box::new(validator) };
280
281 let received_chapter = processor.process_chapter(
282 content,
283 &ValidatorProcessorOptions {
284 hide_invalid: true,
285 invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
286 }
287 );
288
289 let expected_chapter = "whatever
290
291
292other content
293 ";
294 assert_eq!(received_chapter, expected_chapter.to_string());
295 }
296
297 #[test]
298 fn test_content_none_valid_content_still_included_with_warning() {
299 let content = "whatever
300!!!https://github.com/example/example/issues/1
301
302some content to be conditionally included.
303
304!!!
305
306other content
307 ";
308
309 let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::NoneValid };
310
311 let processor = ValidatorProcessor { validator: Box::new(validator) };
312
313 let received_chapter = processor.process_chapter(
314 content,
315 &ValidatorProcessorOptions {
316 hide_invalid: false,
317 invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
318 }
319 );
320
321 let expected_chapter = "whatever
322<div class=\"validated-content\" links=\"https://github.com/example/example/issues/1\">
323
324🚨 Warning, this content is out of date and is included for historical reasons. 🚨
325
326some content to be conditionally included.
327
328
329</div>
330
331other content
332 ";
333 assert_eq!(received_chapter, expected_chapter.to_string());
334 }
335
336 enum ValidateBehavior {
337 AllValid,
338 NoneValid
339 }
340
341 struct FakeIssueValidator {
342 validate_behavior: ValidateBehavior
343 }
344
345 impl IssueValidator for FakeIssueValidator {
346 fn validate(&self, _link: &Issue) -> ValidationResult {
347 match &self.validate_behavior {
348 ValidateBehavior::NoneValid => ValidationResult::NoLongerValid,
349 ValidateBehavior::AllValid => ValidationResult::StillValid
350 }
351 }
352 }
353}