A preprocessor for mdBook that adds interactive exercise blocks with hints, solutions, and optional Rust Playground integration for testing.
- Exercise metadata - Difficulty levels, time estimates, prerequisites
- Learning objectives - Structured thinking/doing outcomes
- Discussion prompts - Reflection questions before coding
- Starter code - Editable code blocks with syntax highlighting
- Progressive hints - Collapsible, leveled hints that reveal incrementally
- Solutions - Hidden by default, reveal on demand
- Test integration - Run tests via Rust Playground or locally
- Progress tracking - LocalStorage-based completion tracking
- Accessible - Keyboard navigation, screen reader support
cargo install mdbook-exercisesgit clone https://github.com/guyernest/mdbook-exercises
cd mdbook-exercises
cargo install --path .cargo install --git https://github.com/guyernest/mdbook-exercises[preprocessor.exercises]Copy the assets to your book's theme directory:
mkdir -p src/theme
cp /path/to/mdbook-exercises/assets/exercises.css src/theme/
cp /path/to/mdbook-exercises/assets/exercises.js src/theme/Then add to your book.toml:
[output.html]
additional-css = ["theme/exercises.css"]
additional-js = ["theme/exercises.js"]# Exercise: Hello World
::: exercise
id: hello-world
difficulty: beginner
time: 10 minutes
:::
Write a function that returns a greeting.
::: starter file="src/lib.rs"
```rust
/// Returns a greeting for the given name
pub fn greet(name: &str) -> String {
// TODO: Return "Hello, {name}!"
todo!()
}
```
:::
::: hint level=1
Use the `format!` macro to create a formatted string.
:::
::: hint level=2
```rust
format!("Hello, {}!", name)
```
:::
::: solution
```rust
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
```
:::
::: tests mode=playground
```rust
#[test]
fn test_greet() {
assert_eq!(greet("World"), "Hello, World!");
}
#[test]
fn test_greet_name() {
assert_eq!(greet("Alice"), "Hello, Alice!");
}
```
:::mdbook build- Browse ready-to-run examples in the
examples/folder:hello-world.md— Beginner Rust exercise with hints, solution, and Playground testscalculator.md— Intermediate Rust with local testsmultilang-python.md— Python exercise (mode=local)multilang-js.md— JavaScript exercise (mode=local)solution-reveal.md— Demonstratesreveal=alwaysch02-environment-setup.md— Setup exercise (ID suffix-setup, position00)
- Minimal sample mdBook (includes examples via
{{#exercise ...}}): seesample-book/ - Live Demo: https://guyernest.github.io/mdbook-exercises/
Defines metadata for the exercise:
::: exercise
id: unique-exercise-id
difficulty: beginner | intermediate | advanced
time: 20 minutes
prerequisites:
- exercise-id-1
- exercise-id-2
:::Learning outcomes in two categories:
::: objectives
thinking:
- Understand concept X
- Recognize pattern Y
doing:
- Implement function Z
- Write tests for edge cases
:::Pre-exercise reflection prompts:
::: discussion
- Why might we want to do X?
- What are the tradeoffs of approach Y?
:::Editable code for the student to complete:
::: starter file="src/main.rs" language=rust
```rust
fn main() {
// TODO: Your code here
}
```
:::Attributes:
file- Suggested filename (displayed in header)language- Syntax highlighting language (default: rust)
Code fence info:
- You can also include the language and optional attributes in the fenced code block info string.
- Supported keys:
filenameorfilefor suggested filename.
Examples:
::: starter
```rust,filename=src/lib.rs
pub fn run() {}
```
:::::: starter file="src/main.rs"
```rust
fn main() {}
```
:::Precedence:
- If both the directive and the fence info specify the same property, the directive attribute wins. For example,
file="src/main.rs"overridesfilename=...in the fence.
Progressive hints with levels:
::: hint level=1 title="Getting Started"
First, consider...
:::
::: hint level=2
Here's more detail...
:::
::: hint level=3 title="Almost There"
```rust
// Nearly complete solution
```
:::Attributes:
level- Hint number (1, 2, 3, etc.)title- Optional title for the hint
The complete solution, hidden by default:
::: solution
```rust
fn solution() {
// Complete implementation
}
```
### Explanation
Why this solution works...
:::Attributes:
reveal—on-demand|always|never(controls visibility)
Rendering:
- The
revealattribute controls visibility:on-demand: Hidden by default unless globally configured to revealalways: Shown expanded regardless of global confignever: Kept hidden; the UI hides the toggle
- Hidden-by-default solutions have a "Show Solution" control
Test code that can optionally run in the browser:
::: tests mode=playground
```rust
#[test]
fn test_example() {
assert!(true);
}
```
:::Attributes:
mode- Eitherplayground(run in browser) orlocal(display only)language- Programming language (defaults to the fence language, if present)
When mode=playground:
- A "Run Tests" button appears
- User code is combined with test code
- Sent to play.rust-lang.org for execution
- Results displayed inline
Code fence info:
- The fence language (e.g.,
```rust) setslanguageif the directive doesn’t specify it. - If both are present, the directive attribute
language=...takes precedence over the fence.
Implementation details:
- Playground execution runs tests as a library (crateType
lib), combining starter code with test code.
Post-exercise questions:
::: reflection
- What did you learn from this exercise?
- How would you extend this solution?
:::When tests have mode=playground, the preprocessor generates JavaScript that:
- Captures the user's code from the editable starter block
- Combines it with the test code
- Sends to the Rust Playground API
- Displays compilation errors or test results
Limitations:
- Only works with
stdlibrary (no external crates) - Subject to playground rate limits
- Requires internet connection
- ~5 second execution timeout
For exercises requiring external crates, use mode=local and guide users to run cargo test locally.
Exercise completion is tracked in localStorage:
- Checkboxes next to learning objectives
- "Mark Complete" button for exercises
- Progress persists across sessions
- No server required
- All interactive elements are keyboard-accessible
- Collapsible sections use proper ARIA attributes
- High contrast mode supported
- Screen reader announcements for test results
[preprocessor.exercises]
# Enable/disable the preprocessor
enabled = true
# Show all hints by default (useful for instructor view)
reveal_hints = false
# Show solutions by default
reveal_solutions = false
# Enable playground integration
playground = true
# Custom playground URL (for private instances)
playground_url = "https://play.rust-lang.org"
# Enable progress tracking
progress_tracking = true
# Automatically copy CSS/JS assets to your book's theme directory
manage_assets = falsemdbook-exercises can be used as a library for parsing exercise markdown:
use mdbook_exercises::{parse_exercise, Exercise};
let markdown = std::fs::read_to_string("exercise.md")?;
let exercise = parse_exercise(&markdown)?;
println!("Exercise: {}", exercise.metadata.id);
println!("Difficulty: {:?}", exercise.metadata.difficulty);
println!("Hints: {}", exercise.hints.len());[dependencies]
# Parser only (no rendering, no mdBook dependency)
mdbook-exercises = { version = "0.1", default-features = false }
# With HTML rendering (no mdBook dependency)
mdbook-exercises = { version = "0.1", default-features = false, features = ["render"] }
# Full mdBook preprocessor (default)
mdbook-exercises = { version = "0.1" }For AI-assisted learning experiences, exercise files can be paired with .ai.toml files containing AI-specific instructions. The parser extracts structured data that MCP servers can use:
use mdbook_exercises::{parse_exercise, Exercise};
// In your MCP server
let exercise = parse_exercise(&markdown)?;
// Access structured data for AI guidance
let starter_code = &exercise.starter.as_ref().unwrap().code;
let hints: Vec<&str> = exercise.hints.iter().map(|h| h.content.as_str()).collect();
let solution = &exercise.solution.as_ref().unwrap().code;See DESIGN.md for details on MCP integration patterns.
See the examples directory for complete exercise examples:
hello-world.md- Basic exercise structurecalculator.md- Multi-hint exercise with testsmultilang-python.md- Non-Rust example (Python), local testsdouble-exercise.md- Two exercises in one chapter (use include syntax in mdBook for best results)
Live Demo: View the rendered examples at guyernest.github.io/mdbook-exercises
- Organizing and integrating exercises (include-only pattern): see
docs/exercises-integration.md. - Setup exercises (ID suffix
-setup, position00): seedocs/setup-exercises.md.
See the sample-book directory for a minimal mdBook configured to use mdbook-exercises (and optionally mdbook-quiz). It includes:
book.tomlwith[preprocessor.exercises]and optional[preprocessor.quiz]src/SUMMARY.md,src/intro.md, andsrc/exercises.mdsrc/exercises.mddemonstrates including exercises via{{#exercise ...}}from this repository’s examples.
You can use mdbook-exercises alongside mdbook-quiz. A common book.toml setup looks like:
[preprocessor.quiz]
# mdbook-quiz configuration here
[preprocessor.exercises]
enabled = true
manage_assets = true # copies exercises.css/js to src/theme/
reveal_hints = false
reveal_solution = false
playground = true
progress_tracking = true
[output.html]
# mdBook will load the installed assets from your theme dir
additional-css = ["theme/exercises.css"]
additional-js = ["theme/exercises.js"]At build time you will see a startup log message similar to mdbook-quiz:
[INFO] (mdbook-exercises): Running the mdbook-exercises preprocessor (vX.Y.Z)
- Starter:
- Directive attributes override fence info (e.g.,
file="..."beatsfilename=...). - Fence language sets default when
languageattribute is omitted.
- Directive attributes override fence info (e.g.,
- Tests:
- Directive
language=...overrides fence language. Fence language sets default when omitted. modeis taken from directive attributes.
- Directive
- Solution:
revealon the solution overrides global config (reveal_solution).
Contributions are welcome! Please open an issue or submit a pull request at GitHub.
MIT OR Apache-2.0