aRustyDev / aRustyDev/mdbook-htmx

docs(mdbook): build process

Open
#31 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
Dominant language
Rust
Stars
0
Forks
1
PR merge metrics
No merged PRs in 30d

Description

# Build Process

This document provides a step-by-step walkthrough of the mdbook-htmx build process, from input to output.

## Overview

```
┌─────────────────┐ ┌─────────────────────────────────┐ ┌─────────────────┐
│ MDBook │────▶│ mdbook-htmx │────▶│ Output Files │
│ (preprocessed) │ │ │ │ │
└─────────────────┘ └─────────────────────────────────┘ └─────────────────┘
│ │
▼ ▼
RenderContext book/htmx/
(JSON stdin) ├── pages/
├── fragments/
├── assets/
└── manifest.json
```

---

## Build Phases

The build process consists of 7 phases:

| Phase | Name | Description |
|-------|------|-------------|
| 1 | Input Parsing | Parse RenderContext from stdin |
| 2 | Configuration | Load and validate config |
| 3 | Template Init | Initialize template engine |
| 4 | Content Rendering | Render each chapter |
| 5 | Output Generation | Write pages and fragments |
| 6 | Asset Processing | Hash and copy assets |
| 7 | Index Generation | Build manifest and search index |

---

## Phase 1: Input Parsing

MDBook invokes the backend as a subprocess, passing `RenderContext` as JSON via stdin.

```rust
fn main() -> Result<()> {
// Read RenderContext from stdin
let ctx: RenderContext = serde_json::from_reader(io::stdin())?;

// Process the book
render(&ctx)?;

Ok(())
}
```

The `RenderContext` contains:

```json
{
"version": "0.4.40",
"root": "/path/to/book",
"book": {
"sections": [...],
"config": {...}
},
"config": {
"book": {...},
"output": {
"htmx": {...}
}
},
"destination": "/path/to/book/book/htmx"
}
```

---

## Phase 2: Configuration

Load configuration from `[output.htmx]` section with validation.

```rust
#[derive(Debug, Deserialize)]
pub struct HtmxConfig {
pub version: Option,
#[serde(default = "default_htmx_version")]
pub htmx_version: String,
#[serde(default = "default_true")]
pub boost: bool,
#[serde(default)]
pub swap_strategy: SwapStrategy,
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_true")]
pub push_url: bool,
#[serde(default)]
pub template_engine: TemplateEngine,
pub theme: Option,
#[serde(default)]
pub output_mode: OutputMode,
#[serde(default)]
pub navigation: NavigationConfig,
#[serde(default)]
pub authn: AuthnConfig,
#[serde(default)]
pub authz: AuthzConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub assets: AssetsConfig,
#[serde(default)]
pub scopes: ScopesConfig,
}

fn load_config(ctx: &RenderContext) -> Result {
let config: HtmxConfig = ctx
.config
.get_deserialized_opt("output.htmx")?
.unwrap_or_default();

validate_config(&config)?;

Ok(config)
}
```

### Configuration Validation

```rust
fn validate_config(config: &HtmxConfig) -> Result<()> {
// Validate version format
if let Some(version) = &config.version {
if !VERSION_PATTERN.is_match(version) {
return Err(ConfigError::InvalidValue {
field: "version".to_string(),
expected: "semver format (e.g., '1.0')".to_string(),
actual: version.clone(),
}.into());
}
}

// Validate scope configuration
if config.scopes.enabled {
if config.scopes.available.is_empty() {
return Err(ConfigError::MissingField {
field: "scopes.available".to_string(),
}.into());
}
}

Ok(())
}
```

---

## Phase 3: Template Initialization

Initialize the template engine with default and custom templates.

```rust
fn init_tera(config: &HtmxConfig, root: &Path) -> Result {
// Load bundled templates
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("layout.html", include_str!("../templates/layout.html")),
("page.html", include_str!("../templates/page.html")),
("sidebar.html", include_str!("../templates/sidebar.html")),
("nav.html", include_str!("../templates/nav.html")),
// ... more templates
])?;

// Override with custom theme if specified
if let Some(theme_path) = &config.theme {
let theme_dir = root.join(theme_path);
if theme_dir.exists() {
let pattern = format!("{}/**/*.html", theme_dir.display());
tera.add_template_files(glob(&pattern)?)?;
}
}

// Register custom functions
tera.register_function("asset", make_asset_fn(&config));
tera.register_function("integrity", make_integrity_fn(&config));

Ok(tera)
}
```

---

## Phase 4: Content Rendering

Iterate through chapters and render each one.

```rust
fn render_chapters(
ctx: &RenderContext,
config: &HtmxConfig,
tera: &Tera,
) -> Result> {
let mut rendered = Vec::new();

for item in ctx.book.iter() {
match item {
BookItem::Chapter(chapter) => {
// Skip draft chapters
if chapter.path.is_none() {
debug!(name = %chapter.name, "Skipping draft chapter");
continue;
}

let result = render_chapter(chapter, config, tera)?;
rendered.push(result);
}
BookItem::Separator => {
// Handle separators in navigation
}
BookItem::PartTitle(title) => {
// Handle part titles
}
}
}

Ok(rendered)
}

fn render_chapter(
chapter: &Chapter,
config: &HtmxConfig,
tera: &Tera,
) -> Result {
// Extract frontmatter
let (frontmatter, content) = extract_frontmatter(&chapter.content)?;

// Convert markdown to HTML
let html_content = markdown_to_html(&content);

// Build template context
let mut context = Context::new();
context.insert("page", &PageContext {
path: chapter.path.as_ref().unwrap(),
title: &chapter.name,
content: &html_content,
});
context.insert("config", config);
context.insert("frontmatter", &frontmatter);

// Render full page
let full_page = tera.render("page.html", &context)?;

// Render fragment (content only)
let fragment = tera.render("fragment.html", &context)?;

Ok(RenderedChapter {
path: chapter.path.clone().unwrap(),
title: chapter.name.clone(),
full_page,
fragment,
frontmatter,
})
}
```

### Frontmatter Extraction

```rust
fn extract_frontmatter(content: &str) -> Result<(Frontmatter, String)> {
if !content.starts_with("---") {
return Ok((Frontmatter::default(), content.to_string()));
}

let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(FrontmatterError::Unclosed.into());
}

let yaml = parts[1].trim();
let markdown = parts[2].trim();

let frontmatter: Frontmatter = serde_yaml::from_str(yaml)?;
validate_frontmatter(&frontmatter)?;

Ok((frontmatter, markdown.to_string()))
}
```

---

## Phase 5: Output Generation

Write rendered pages and fragments to the destination directory.

```rust
fn write_output(
rendered: &[RenderedChapter],
config: &HtmxConfig,
destination: &Path,
) -> Result<()> {
// Create output directories
let pages_dir = destination.join("pages");
let fragments_dir = destination.join("fragments");

fs::create_dir_all(&pages_dir)?;
fs::create_dir_all(&fragments_dir)?;

for chapter in rendered {
// Convert chapter path to output path
let page_path = pages_dir.join(&chapter.path).with_extension("html");
let fragment_path = fragments_dir.join(&chapter.path).with_extension("html");

// Ensure parent directories exist
if let Some(parent) = page_path.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = fragment_path.parent() {
fs::create_dir_all(parent)?;
}

// Write files based on output mode
match config.output_mode {
OutputMode::Full => {
fs::write(&page_path, &chapter.full_page)?;
}
OutputMode::Fragments => {
fs::write(&fragment_path, &chapter.fragment)?;
}
OutputMode::Both => {
fs::write(&page_path, &chapter.full_page)?;
fs::write(&fragment_path, &chapter.fragment)?;
}
}
}

Ok(())
}
```

---

## Phase 6: Asset Processing

Hash assets for cache busting and copy to output.

```rust
fn process_assets(
config: &HtmxConfig,
root: &Path,
destination: &Path,
) -> Result {
let assets_src = root.join("assets");
let assets_dest = destination.join("assets");

let mut hasher = AssetHasher::new();

for entry in WalkDir::new(&assets_src) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}

let rel_path = entry.path().strip_prefix(&assets_src)?;

if config.assets.hash_files && should_hash(entry.path()) {
// Hash and copy with new name
let hashed_name = hasher.hash_file(entry.path())?;
let dest_path = assets_dest
.join(rel_path.parent().unwrap_or(Path::new("")))
.join(&hashed_name);

fs::create_dir_all(dest_path.parent().unwrap())?;
fs::copy(entry.path(), &dest_path)?;
} else {
// Copy as-is
let dest_path = assets_dest.join(rel_path);
fs::create_dir_all(dest_path.parent().unwrap())?;
fs::copy(entry.path(), &dest_path)?;
}
}

// Copy HTMX library
if config.assets.copy_htmx {
let htmx_content = include_bytes!("../vendor/htmx.min.js");
let htmx_path = assets_dest.join("js/htmx.min.js");
fs::create_dir_all(htmx_path.parent().unwrap())?;
fs::write(&htmx_path, htmx_content)?;
}

// Write asset manifest
hasher.write_manifest(&destination.join("asset-manifest.json"))?;

Ok(hasher.to_manifest())
}
```

---

## Phase 7: Index Generation

Generate manifest.json and search indexes.

```rust
fn generate_indexes(
rendered: &[RenderedChapter],
config: &HtmxConfig,
ctx: &RenderContext,
destination: &Path,
) -> Result<()> {
// Generate main manifest
let manifest = build_manifest(rendered, config, ctx)?;
let manifest_json = serde_json::to_string_pretty(&manifest)?;
fs::write(destination.join("manifest.json"), &manifest_json)?;

// Generate scope-filtered manifests
if config.scopes.enabled {
for scope in &config.scopes.available {
let filtered = filter_manifest_by_scope(&manifest, scope);
let path = destination.join(format!("manifest.{}.json", scope));
fs::write(&path, serde_json::to_string_pretty(&filtered)?)?;
}
}

// Generate search index
if config.search.enabled {
let search_index = build_search_index(rendered, config)?;
let index_json = serde_json::to_string_pretty(&search_index)?;
fs::write(destination.join("search-index.json"), &index_json)?;

// Generate scope-filtered search indexes
if config.search.scope_indexes {
for scope in &config.scopes.available {
let filtered = filter_search_index_by_scope(&search_index, scope);
let path = destination.join(format!("search-index.{}.json", scope));
fs::write(&path, serde_json::to_string_pretty(&filtered)?)?;
}
}
}

Ok(())
}
```

### Manifest Building

```rust
fn build_manifest(
rendered: &[RenderedChapter],
config: &HtmxConfig,
ctx: &RenderContext,
) -> Result {
let pages: Vec = rendered.iter().map(|chapter| {
ManifestPage {
id: chapter.path.file_stem().unwrap().to_string(),
path: format!("/docs/{}", chapter.path.with_extension("").display()),
title: chapter.title.clone(),
file: format!("pages/{}.html", chapter.path.with_extension("").display()),
fragment: format!("fragments/{}.html", chapter.path.with_extension("").display()),
auth: chapter.frontmatter.auth.clone(),
htmx: chapter.frontmatter.htmx.clone(),
search: chapter.frontmatter.search.clone(),
scopes: chapter.frontmatter.scopes.clone(),
}
}).collect();

Ok(Manifest {
schema: MANIFEST_SCHEMA_URL.to_string(),
version: MANIFEST_VERSION.to_string(),
generated: Utc::now().to_rfc3339(),
book: BookMeta::from(ctx),
pages,
navigation: build_navigation(ctx)?,
authn: Some(config.authn.clone()),
scopes: Some(config.scopes.clone()),
})
}
```

---

## Build Output

After a successful build:

```
book/htmx/
├── pages/
│ ├── index.html
│ ├── getting-started.html
│ └── api/
│ └── reference.html
├── fragments/
│ ├── index.html
│ ├── getting-started.html
│ └── api/
│ └── reference.html
├── assets/
│ ├── css/
│ │ └── docs.a1b2c3d4.css
│ └── js/
│ └── htmx.min.js
├── templates/
│ ├── layout.html
│ └── partials/
│ └── *.html
├── manifest.json
├── manifest.developers.json
├── manifest.managers.json
├── search-index.json
├── search-index.developers.json
└── asset-manifest.json
```

---

## Error Handling

Build errors are reported with context:

```
error: auth.access is 'roles' but no roles specified
--> src/admin/config.md:3
|
1 | ---
2 | auth:
3 | access: roles
| ^^^^^ missing 'roles' field
4 | ---
|

help: Add 'roles: [role1, role2]' to the auth section
```

Exit codes:

| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Build errors |
| 2 | Configuration errors |
| 3 | I/O errors |
| 4 | Internal errors |

---

## CLI Options

```bash
# Default build
mdbook build

# Verbose output
mdbook build -v

# Continue despite errors
mdbook build --keep-going

# Warnings as errors
mdbook build --deny warnings
```

---

## Related Documentation

- [ADR-0012: MDBook Renderer Trait Implementation](../adr/0012-mdbook-renderer-trait-implementation.md)
- [ADR-0016: Implementation Phasing Strategy](../adr/0016-implementation-phasing-strategy.md)
- [ADR-0017: Error Handling and Build Failures](../adr/0017-error-handling-and-build-failures.md)
- [Render Context Reference](./render-context.md)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.