mdbook_exercises/
preprocessor.rs1use crate::parser::parse_exercise;
7use crate::render::{render_exercise_with_config, RenderConfig};
8use mdbook::book::{Book, BookItem};
9use mdbook::errors::Error;
10use mdbook::preprocess::{Preprocessor, PreprocessorContext};
11use regex::Regex;
12use std::path::Path;
13
14pub struct ExercisesPreprocessor;
16
17impl ExercisesPreprocessor {
18 pub fn new() -> ExercisesPreprocessor {
20 ExercisesPreprocessor
21 }
22
23 fn load_config(ctx: &PreprocessorContext) -> RenderConfig {
25 let mut config = RenderConfig::default();
26
27 if let Some(exercises_config) = ctx.config.get("preprocessor.exercises") {
28 if let Some(enabled) = exercises_config.get("enabled") {
29 config.enabled = enabled.as_bool().unwrap_or(true);
30 }
31 if let Some(reveal_hints) = exercises_config.get("reveal_hints") {
32 config.reveal_hints = reveal_hints.as_bool().unwrap_or(false);
33 }
34 if let Some(reveal_solution) = exercises_config.get("reveal_solution") {
35 config.reveal_solution = reveal_solution.as_bool().unwrap_or(false);
36 }
37 if let Some(playground) = exercises_config.get("playground") {
38 config.enable_playground = playground.as_bool().unwrap_or(true);
39 }
40 if let Some(playground_url) = exercises_config.get("playground_url") {
41 if let Some(url) = playground_url.as_str() {
42 config.playground_url = url.to_string();
43 }
44 }
45 if let Some(progress) = exercises_config.get("progress_tracking") {
46 config.enable_progress = progress.as_bool().unwrap_or(true);
47 }
48 if let Some(manage_assets) = exercises_config.get("manage_assets") {
49 config.manage_assets = manage_assets.as_bool().unwrap_or(false);
50 }
51 }
52
53 config
54 }
55
56 fn process_chapter(content: &str, config: &RenderConfig) -> Result<String, Error> {
58 if !content.contains("::: exercise") && !content.contains("::: usecase") {
60 return Ok(content.to_string());
61 }
62
63 match parse_exercise(content) {
65 Ok(exercise) => {
66 match render_exercise_with_config(&exercise, config) {
68 Ok(html) => {
69 let replaced = Self::replace_exercise_region(content, &html);
76 Ok(replaced)
77 }
78 Err(e) => {
79 Ok(format!(
81 "<!-- Exercise render error: {} -->\n\n{}",
82 e, content
83 ))
84 }
85 }
86 }
87 Err(e) => {
88 Ok(format!(
90 "<!-- Exercise parse error: {} -->\n\n{}",
91 e, content
92 ))
93 }
94 }
95 }
96}
97
98impl Default for ExercisesPreprocessor {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl Preprocessor for ExercisesPreprocessor {
105 fn name(&self) -> &str {
106 "exercises"
107 }
108
109 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
110 eprintln!(
112 "[INFO] (mdbook-exercises): Running the mdbook-exercises preprocessor (v{})",
113 env!("CARGO_PKG_VERSION")
114 );
115
116 let config = Self::load_config(ctx);
117
118 if !config.enabled {
119 eprintln!("[INFO] (mdbook-exercises): Disabled by configuration; skipping.");
120 return Ok(book);
121 }
122
123 if config.manage_assets {
124 if let Err(e) = Self::install_assets(ctx) {
125 eprintln!("[WARN] (mdbook-exercises): Failed to install assets: {}", e);
126 } else {
127 eprintln!("[INFO] (mdbook-exercises): Assets installed to book theme directory.");
128 }
129 } else {
130 if let Some(hint) = Self::asset_setup_hint(ctx) {
132 eprintln!("[INFO] (mdbook-exercises): {}", hint);
133 }
134 }
135
136 book.for_each_mut(|item| {
138 if let BookItem::Chapter(chapter) = item {
139 if let Some(ref mut content) = Some(&mut chapter.content) {
140 match Self::process_chapter(content, &config) {
141 Ok(new_content) => {
142 chapter.content = new_content;
143 }
144 Err(e) => {
145 eprintln!(
146 "Warning: Failed to process exercises in {}: {}",
147 chapter.name, e
148 );
149 }
150 }
151 }
152 }
153 });
154
155 Ok(book)
156 }
157
158 fn supports_renderer(&self, renderer: &str) -> bool {
159 renderer == "html"
161 }
162}
163
164pub struct ExerciseIncludeProcessor {
175 config: RenderConfig,
176 book_root: std::path::PathBuf,
177}
178
179impl ExerciseIncludeProcessor {
180 pub fn new(book_root: &Path, config: RenderConfig) -> Self {
182 Self {
183 config,
184 book_root: book_root.to_path_buf(),
185 }
186 }
187
188 pub fn process(&self, content: &str) -> Result<String, Error> {
190 let include_re = Regex::new(r"\{\{#exercise\s+([^}]+)\}\}")
191 .map_err(|e| Error::msg(format!("Regex error: {}", e)))?;
192
193 let mut result = content.to_string();
194
195 for cap in include_re.captures_iter(content) {
196 let full_match = cap.get(0).unwrap().as_str();
197 let exercise_path = cap.get(1).unwrap().as_str().trim();
198
199 let full_path = self.book_root.join(exercise_path);
200
201 match std::fs::read_to_string(&full_path) {
202 Ok(exercise_content) => match parse_exercise(&exercise_content) {
203 Ok(exercise) => match render_exercise_with_config(&exercise, &self.config) {
204 Ok(html) => {
205 let wrapped = format!(
206 r#"<div class="exercise-container">
207{}
208</div>"#,
209 html
210 );
211 result = result.replace(full_match, &wrapped);
212 }
213 Err(e) => {
214 let error_html = format!(
215 r#"<div class="exercise-error">
216 <p><strong>Error rendering exercise:</strong> {}</p>
217 <p>File: {}</p>
218</div>"#,
219 e, exercise_path
220 );
221 result = result.replace(full_match, &error_html);
222 }
223 },
224 Err(e) => {
225 let error_html = format!(
226 r#"<div class="exercise-error">
227 <p><strong>Error parsing exercise:</strong> {}</p>
228 <p>File: {}</p>
229</div>"#,
230 e, exercise_path
231 );
232 result = result.replace(full_match, &error_html);
233 }
234 },
235 Err(e) => {
236 let error_html = format!(
237 r#"<div class="exercise-error">
238 <p><strong>Error loading exercise file:</strong> {}</p>
239 <p>File: {}</p>
240</div>"#,
241 e, exercise_path
242 );
243 result = result.replace(full_match, &error_html);
244 }
245 }
246 }
247
248 Ok(result)
249 }
250}
251
252pub struct FullExercisesPreprocessor;
254
255impl FullExercisesPreprocessor {
256 pub fn new() -> Self {
257 Self
258 }
259}
260
261impl Default for FullExercisesPreprocessor {
262 fn default() -> Self {
263 Self::new()
264 }
265}
266
267impl Preprocessor for FullExercisesPreprocessor {
268 fn name(&self) -> &str {
269 "exercises"
270 }
271
272 fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
273 eprintln!(
275 "[INFO] (mdbook-exercises): Running the mdbook-exercises preprocessor (v{})",
276 env!("CARGO_PKG_VERSION")
277 );
278
279 let config = ExercisesPreprocessor::load_config(ctx);
280 if !config.enabled {
281 eprintln!("[INFO] (mdbook-exercises): Disabled by configuration; skipping.");
282 return Ok(book);
283 }
284 if config.manage_assets {
285 if let Err(e) = ExercisesPreprocessor::install_assets(ctx) {
286 eprintln!("[WARN] (mdbook-exercises): Failed to install assets: {}", e);
287 } else {
288 eprintln!("[INFO] (mdbook-exercises): Assets installed to book theme directory.");
289 }
290 } else {
291 if let Some(hint) = ExercisesPreprocessor::asset_setup_hint(ctx) {
292 eprintln!("[INFO] (mdbook-exercises): {}", hint);
293 }
294 }
295 let book_root = ctx.root.join(&ctx.config.book.src);
296
297 book.for_each_mut(|item| {
298 if let BookItem::Chapter(chapter) = item {
299 let content = &chapter.content;
300
301 let include_processor = ExerciseIncludeProcessor::new(&book_root, config.clone());
303 let after_includes = match include_processor.process(content) {
304 Ok(c) => c,
305 Err(e) => {
306 eprintln!(
307 "Warning: Failed to process exercise includes in {}: {}",
308 chapter.name, e
309 );
310 content.clone()
311 }
312 };
313
314 let final_content =
316 match ExercisesPreprocessor::process_chapter(&after_includes, &config) {
317 Ok(c) => c,
318 Err(e) => {
319 eprintln!(
320 "Warning: Failed to process inline exercises in {}: {}",
321 chapter.name, e
322 );
323 after_includes
324 }
325 };
326
327 chapter.content = final_content;
328 }
329 });
330
331 Ok(book)
332 }
333
334 fn supports_renderer(&self, renderer: &str) -> bool {
335 renderer == "html"
336 }
337}
338
339impl ExercisesPreprocessor {
340 fn replace_exercise_region(content: &str, rendered_html: &str) -> String {
342 let re_start = Regex::new(r"(?m)^\s*:::\s+(exercise|usecase)\b").unwrap();
344 let Some(m) = re_start.find(content) else { return content.to_string(); };
345
346 let re_open = Regex::new(r"^\s*:::\s+[a-zA-Z]").unwrap();
348 let re_close = Regex::new(r"^\s*:::\s*$").unwrap();
349 let mut open: i32 = 0;
350 let mut in_region = false;
351 let mut end_idx = content.len();
352 let mut offset = 0usize;
353 for line in content.split_inclusive('\n') {
354 let ls = offset;
355 let le = offset + line.len();
356 offset = le;
357 if ls < m.start() { continue; }
358 let t = line.trim_end_matches(['\n','\r']);
359 if re_open.is_match(t) {
360 if !in_region { in_region = true; }
361 open += 1;
362 } else if in_region && re_close.is_match(t) {
363 open -= 1;
364 if open <= 0 { end_idx = le; break; }
365 }
366 }
367
368 let mut out = String::new();
369 out.push_str(&content[..m.start()]);
370 out.push_str(&format!("<div class=\"exercise-container\">\n{}\n</div>\n", rendered_html));
371 out.push_str(&content[end_idx..]);
372 out
373 }
374
375 fn install_assets(ctx: &PreprocessorContext) -> Result<(), Error> {
377 use std::fs;
378 use std::io::Write;
379 let theme_dir = ctx.root.join(&ctx.config.book.src).join("theme");
380 fs::create_dir_all(&theme_dir)
381 .map_err(|e| Error::msg(format!("Failed to create theme dir {}: {}", theme_dir.display(), e)))?;
382
383 const CSS: &str = include_str!("../assets/exercises.css");
385 const JS: &str = include_str!("../assets/exercises.js");
386
387 let css_path = theme_dir.join("exercises.css");
388 let js_path = theme_dir.join("exercises.js");
389
390 {
392 let mut f = fs::File::create(&css_path)
393 .map_err(|e| Error::msg(format!("Failed to write {}: {}", css_path.display(), e)))?;
394 f.write_all(CSS.as_bytes())
395 .map_err(|e| Error::msg(format!("Failed to write {}: {}", css_path.display(), e)))?;
396 }
397 {
399 let mut f = fs::File::create(&js_path)
400 .map_err(|e| Error::msg(format!("Failed to write {}: {}", js_path.display(), e)))?;
401 f.write_all(JS.as_bytes())
402 .map_err(|e| Error::msg(format!("Failed to write {}: {}", js_path.display(), e)))?;
403 }
404
405 Ok(())
406 }
407
408 fn asset_setup_hint(ctx: &PreprocessorContext) -> Option<String> {
410 use std::fs;
411 let theme_dir = ctx.root.join(&ctx.config.book.src).join("theme");
412 let css_path = theme_dir.join("exercises.css");
413 let js_path = theme_dir.join("exercises.js");
414
415 let css_exists = fs::metadata(&css_path).is_ok();
416 let js_exists = fs::metadata(&js_path).is_ok();
417 if css_exists && js_exists {
418 return None;
419 }
420
421 Some(format!(
422 "Assets not found under '{}'. Either enable manage_assets = true or copy assets manually and reference them in [output.html]: additional-css=['theme/exercises.css'], additional-js=['theme/exercises.js']",
423 theme_dir.display()
424 ))
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn test_process_chapter_no_exercises() {
434 let content = "# Just a normal chapter\n\nSome content here.";
435 let config = RenderConfig::default();
436
437 let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
438
439 assert_eq!(result, content);
441 }
442
443 #[test]
444 fn test_process_chapter_with_exercise() {
445 let content = r#"# My Exercise
446
447::: exercise
448id: test-ex
449difficulty: beginner
450:::
451
452Some description.
453
454::: starter
455```rust
456fn main() {}
457```
458:::
459"#;
460 let config = RenderConfig::default();
461
462 let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
463
464 assert!(result.contains("exercise-container"));
466 assert!(result.contains("test-ex"));
467 }
468
469 #[test]
470 fn test_process_chapter_with_usecase() {
471 let content = r#"# My UseCase
472
473::: usecase
474id: test-uc
475domain: general
476difficulty: beginner
477:::
478
479::: scenario
480Scen...
481:::
482
483::: prompt
484Prompt...
485:::
486"#;
487 let config = RenderConfig::default();
488
489 let result = ExercisesPreprocessor::process_chapter(content, &config).unwrap();
490
491 assert!(result.contains("exercise-container"));
493 assert!(result.contains("test-uc"));
494 assert!(result.contains("usecase-exercise"));
495 }
496}