aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0012: MDBook Renderer Trait Implementation
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0012: MDBook Renderer Trait Implementation
## Status
Accepted
## Context
mdbook-htmx is an alternative backend for MDBook. To integrate properly with MDBook's build pipeline, we need to understand and implement the `Renderer` trait from `mdbook-renderer` crate, and correctly consume the `RenderContext` JSON structure passed via stdin.
This ADR documents the integration approach and the data structures we receive from MDBook.
## Decision Drivers
1. **Compatibility** - Must work with standard `mdbook build` command
2. **Future-Proofing** - Handle MDBook version changes gracefully
3. **Extension** - Support custom frontmatter beyond MDBook's standard fields
4. **Error Handling** - Fail gracefully with actionable error messages
## Decision
**Implement as a standalone binary that reads RenderContext JSON from stdin.**
This is the standard pattern for MDBook backends, allowing:
- Installation via `cargo install mdbook-htmx`
- Invocation via `[output.htmx]` in book.toml
- No changes required to MDBook itself
## RenderContext Structure
MDBook passes a JSON representation of `RenderContext` via stdin:
```json
{
"version": "0.4.40",
"root": "/path/to/book",
"book": {
"items": [
{
"Chapter": {
"name": "Introduction",
"content": "# Introduction\n\nWelcome to...",
"number": [1],
"sub_items": [],
"path": "intro.md",
"source_path": "intro.md",
"parent_names": []
}
},
"Separator",
{
"PartTitle": "Getting Started"
},
{
"Chapter": {
"name": "Installation",
"content": "# Installation\n\n...",
"number": [2],
"sub_items": [
{
"Chapter": {
"name": "Linux",
"content": "...",
"number": [2, 1],
"sub_items": [],
"path": "install/linux.md",
"source_path": "install/linux.md",
"parent_names": ["Installation"]
}
}
],
"path": "install/index.md",
"source_path": "install/README.md",
"parent_names": []
}
}
]
},
"config": {
"book": {
"title": "Example Book",
"authors": ["John Doe"],
"description": "An example book",
"src": "src",
"language": "en"
},
"build": {
"build-dir": "book",
"create-missing": true,
"use-default-preprocessors": true
},
"output": {
"htmx": {
"version": "2.0.4",
"boost": true,
"template-engine": "tera"
}
}
},
"destination": "/path/to/book/htmx"
}
```
### Key Fields
| Field | Type | Description |
|-------|------|-------------|
| `version` | String | MDBook version (for compatibility checks) |
| `root` | PathBuf | Book's root directory |
| `book.items` | Vec | Tree of chapters, separators, part titles |
| `config` | Config | Full book.toml configuration |
| `destination` | PathBuf | Output directory for this renderer |
### BookItem Variants
```rust
enum BookItem {
Chapter(Chapter),
Separator,
PartTitle(String),
}
```
### Chapter Structure
| Field | Type | Description |
|-------|------|-------------|
| `name` | String | Chapter title |
| `content` | String | Raw markdown content |
| `number` | Option> | Section number (e.g., [1, 2] = "1.2.") |
| `sub_items` | Vec | Nested chapters |
| `path` | Option | Logical path (README → index.md) |
| `source_path` | Option | Actual file path |
| `parent_names` | Vec | Ancestor chapter names |
## Implementation
### Entry Point
```rust
use std::io;
use mdbook_renderer::RenderContext;
fn main() -> anyhow::Result<()> {
// Initialize logging
env_logger::init();
// Check for "supports" subcommand
if let Some(arg) = std::env::args().nth(1) {
if arg == "supports" {
// We support all renderers
return Ok(());
}
}
// Read RenderContext from stdin
let ctx = RenderContext::from_json(&mut io::stdin())?;
// Validate MDBook version
check_version(&ctx.version)?;
// Load our configuration
let config: HtmxConfig = ctx.config
.get_deserialized("output.htmx")?
.unwrap_or_default();
// Render the book
let renderer = HtmxRenderer::new(config);
renderer.render(&ctx)?;
Ok(())
}
```
### Version Compatibility
```rust
fn check_version(version: &str) -> anyhow::Result<()> {
let semver: semver::Version = version.parse()?;
// Require MDBook 0.4.21+ for RenderContext stability
let minimum = semver::Version::new(0, 4, 21);
if semver < minimum {
anyhow::bail!(
"mdbook-htmx requires MDBook {} or later, found {}",
minimum, version
);
}
Ok(())
}
```
### Accessing Custom Configuration
```rust
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct HtmxConfig {
pub version: String,
pub boost: bool,
pub swap_strategy: String,
pub target: String,
pub push_url: bool,
pub template_engine: String,
pub theme: Option,
pub output_mode: OutputMode,
pub navigation: NavigationConfig,
pub authz: AuthzConfig,
pub authn: AuthnConfig,
pub search: SearchConfig,
pub assets: AssetsConfig,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum OutputMode {
Full,
Fragments,
#[default]
Both,
}
```
## Rendering Pipeline
1. **Parse RenderContext** - Deserialize JSON from stdin
2. **Load Configuration** - Extract `[output.htmx]` settings
3. **Initialize Tera** - Load templates from theme or defaults
4. **Register Custom Filters** - Add `asset()`, `url()`, etc.
5. **Iterate Chapters** - Walk `book.items` depth-first
6. **Extract Frontmatter** - Parse YAML from chapter content
7. **Render HTML** - Apply Tera templates to each chapter
8. **Generate Manifest** - Build manifest.json from collected metadata
9. **Build Search Index** - Generate search-index.json
10. **Copy Assets** - Handle CSS, JS, images
```rust
impl Renderer for HtmxRenderer {
fn name(&self) -> &str {
"htmx"
}
fn render(&self, ctx: &RenderContext) -> Result<()> {
// Ensure destination exists
fs::create_dir_all(&ctx.destination)?;
// Initialize template engine
let tera = self.init_tera(ctx)?;
// Collect page metadata
let mut pages = Vec::new();
// Render each chapter
for item in ctx.book.iter() {
if let BookItem::Chapter(ch) = item {
if ch.is_draft_chapter() {
continue;
}
let page = self.render_chapter(ctx, &tera, ch)?;
pages.push(page);
}
}
// Generate manifest
self.write_manifest(ctx, &pages)?;
// Generate search index
if self.config.search.enabled {
self.write_search_index(ctx, &pages)?;
}
// Copy assets
self.copy_assets(ctx)?;
Ok(())
}
}
```
## Custom Tera Filters
Register filters for template use:
```rust
fn init_tera(&self, ctx: &RenderContext) -> Result {
let mut tera = Tera::default();
// Load templates from theme or defaults
let templates_dir = self.config.theme
.as_ref()
.map(|t| ctx.root.join(t))
.unwrap_or_else(|| self.default_templates());
tera.add_template_files(/* ... */)?;
// Register custom filters
let destination = ctx.destination.clone();
tera.register_function("asset", make_asset_fn(destination));
// Content filters
tera.register_filter("markdown", markdown_filter);
tera.register_filter("toc", toc_filter);
Ok(tera)
}
fn make_asset_fn(destination: PathBuf) -> impl Function {
Box::new(move |args: &HashMap| -> tera::Result {
match args.get("path") {
Some(val) => {
let path: String = from_value(val.clone())?;
// In production, would add content hash
Ok(to_value(format!("/assets/{}", path))?)
}
None => Err("asset() requires 'path' argument".into()),
}
})
}
```
## Error Handling
Fail with clear, actionable errors:
```rust
// Bad: process::exit(1) with no message
// Good: anyhow with context
fn render_chapter(&self, ctx: &RenderContext, tera: &Tera, ch: &Chapter)
-> Result
{
let path = ch.path.as_ref()
.ok_or_else(|| anyhow::anyhow!(
"Chapter '{}' has no path (draft chapter?)", ch.name
))?;
let frontmatter = extract_frontmatter(&ch.content)
.with_context(|| format!(
"Failed to parse frontmatter in '{}'", path.display()
))?;
let html = tera.render("page.html", &context)
.with_context(|| format!(
"Failed to render template for '{}'", path.display()
))?;
Ok(PageMeta { /* ... */ })
}
```
## Consequences
### Positive
- Standard MDBook integration pattern
- Receives full book structure including preprocessor output
- Access to complete configuration
- Works with existing MDBook tooling (`mdbook watch`, etc.)
### Negative
- No incremental builds (MDBook limitation)
- Must re-render entire book on any change
- Large books may have slow build times
### Mitigation
- Cache rendered fragments by content hash
- Parallelize chapter rendering with Rayon
- Consider implementing incremental rendering in future MDBook versions
## Alternatives Considered
### Library Integration
Implement as library linked into MDBook.
**Rejected** because:
- Requires MDBook code changes
- Harder to distribute
- No standard pattern
### Preprocessor Instead of Backend
Implement as a preprocessor that modifies content.
**Rejected** because:
- Preprocessors can't control output structure
- Can't generate manifest.json
- Wrong abstraction level
## References
- [MDBook Backend Development](https://rust-lang.github.io/mdBook/for_developers/backends.html)
- [mdbook-renderer crate](https://docs.rs/mdbook-renderer/)
- [Tera Template Engine](https://keats.github.io/tera/)
Contributor guide
Assessment
This issue has not been assessed yet.