aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0013: Frontmatter Schema and Validation
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0013: Frontmatter Schema and Validation
## Status
Accepted
## Context
mdbook-htmx needs to extract metadata from chapter frontmatter for:
- Authorization rules (roles, access level)
- Scope assignments (audience filtering)
- HTMX behaviors (lazy loading, caching)
- Search configuration (indexing, weight)
MDBook doesn't process frontmatter—it passes raw markdown content to renderers. We need to define a schema for frontmatter extraction and validation.
## Decision Drivers
1. **Compatibility** - Don't break standard MDBook behavior
2. **Validation** - Catch errors at build time, not runtime
3. **Extensibility** - Allow custom fields for future features
4. **Documentation** - Schema serves as documentation
## Decision
**Extract and validate YAML frontmatter using a JSON Schema, with graceful handling of unknown fields.**
### Frontmatter Location
Frontmatter is placed at the start of markdown files:
```markdown
---
title: Admin Configuration
auth:
access: roles
roles: [admin, editor]
fallback: /docs/access-denied
scopes: [developers, managers]
htmx:
lazy: true
cache: 3600
---
# Admin Configuration
Content here...
```
### Extraction Strategy
```rust
use serde::Deserialize;
use serde_yaml;
const FRONTMATTER_DELIMITER: &str = "---";
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct Frontmatter {
pub title: Option,
pub auth: AuthConfig,
pub scopes: Vec,
pub htmx: HtmxPageConfig,
pub search: SearchPageConfig,
// Capture unknown fields for extensibility
#[serde(flatten)]
pub extra: HashMap,
}
pub fn extract_frontmatter(content: &str) -> Result<(Frontmatter, String)> {
let content = content.trim();
if !content.starts_with(FRONTMATTER_DELIMITER) {
// No frontmatter, return defaults
return Ok((Frontmatter::default(), content.to_string()));
}
// Find closing delimiter
let rest = &content[3..];
let end = rest.find(FRONTMATTER_DELIMITER)
.ok_or_else(|| anyhow!("Unclosed frontmatter"))?;
let yaml = &rest[..end].trim();
let body = &rest[end + 3..].trim_start();
let frontmatter: Frontmatter = serde_yaml::from_str(yaml)?;
Ok((frontmatter, body.to_string()))
}
```
## Schema Definition
### JSON Schema
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.arusty.dev/mdbook-htmx/frontmatter.schema.json",
"title": "mdbook-htmx Frontmatter Schema",
"description": "Schema for validating chapter frontmatter in mdbook-htmx",
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Override chapter title (defaults to first H1)"
},
"auth": {
"$ref": "#/$defs/auth"
},
"scopes": {
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "Audience scopes this page belongs to"
},
"htmx": {
"$ref": "#/$defs/htmx"
},
"search": {
"$ref": "#/$defs/search"
}
},
"additionalProperties": true,
"$defs": {
"auth": {
"type": "object",
"properties": {
"access": {
"type": "string",
"enum": ["public", "authenticated", "roles"],
"default": "public",
"description": "Access control level"
},
"roles": {
"type": "array",
"items": { "type": "string" },
"description": "Required roles (when access=roles)"
},
"fallback": {
"type": "string",
"format": "uri-reference",
"description": "Redirect URL for unauthorized access"
}
},
"if": {
"properties": { "access": { "const": "roles" } }
},
"then": {
"required": ["roles"]
}
},
"htmx": {
"type": "object",
"properties": {
"lazy": {
"type": "boolean",
"default": false,
"description": "Load content on reveal (hx-trigger='revealed')"
},
"cache": {
"type": "integer",
"minimum": 0,
"description": "Cache-Control max-age in seconds"
},
"preload": {
"type": "boolean",
"default": false,
"description": "Preload on hover (hx-preload extension)"
},
"boost": {
"type": "boolean",
"description": "Override global hx-boost setting"
}
}
},
"search": {
"type": "object",
"properties": {
"indexed": {
"type": "boolean",
"default": true,
"description": "Include in search index"
},
"weight": {
"type": "number",
"minimum": 0,
"maximum": 10,
"default": 1.0,
"description": "Search result ranking weight"
},
"keywords": {
"type": "array",
"items": { "type": "string" },
"description": "Additional search keywords"
}
}
}
}
}
```
### Rust Types
```rust
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct AuthConfig {
pub access: AccessLevel,
pub roles: Vec,
pub fallback: Option,
}
#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AccessLevel {
#[default]
Public,
Authenticated,
Roles,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct HtmxPageConfig {
pub lazy: bool,
pub cache: Option,
pub preload: bool,
pub boost: Option,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct SearchPageConfig {
pub indexed: bool,
pub weight: f32,
pub keywords: Vec,
}
impl Default for SearchPageConfig {
fn default() -> Self {
Self {
indexed: true,
weight: 1.0,
keywords: Vec::new(),
}
}
}
```
## Validation Strategy
### Build-Time Validation
Validate frontmatter during the build process:
```rust
impl HtmxRenderer {
fn validate_frontmatter(
&self,
frontmatter: &Frontmatter,
chapter_path: &Path,
) -> Result<()> {
// Validate auth configuration
if frontmatter.auth.access == AccessLevel::Roles {
if frontmatter.auth.roles.is_empty() {
anyhow::bail!(
"{}: auth.access is 'roles' but no roles specified",
chapter_path.display()
);
}
}
// Validate scopes against configured scopes
for scope in &frontmatter.scopes {
if !self.config.scopes.available.contains(scope) {
log::warn!(
"{}: Unknown scope '{}'. Available: {:?}",
chapter_path.display(),
scope,
self.config.scopes.available
);
}
}
// Validate fallback URL format
if let Some(fallback) = &frontmatter.auth.fallback {
if !fallback.starts_with('/') && !fallback.starts_with("http") {
anyhow::bail!(
"{}: auth.fallback must be absolute path or URL: {}",
chapter_path.display(),
fallback
);
}
}
Ok(())
}
}
```
### CLI Validation Tool
Optional pre-build validation:
```bash
$ mdbook-htmx validate src/
Validating frontmatter...
✓ src/intro.md
✓ src/chapter-1.md
✗ src/admin/config.md
Error: auth.access is 'roles' but no roles specified
Found 1 error in 15 files.
```
## Scope Interaction
Frontmatter `scopes` array determines which scope-filtered outputs include the page:
```yaml
---
scopes: [developers, managers]
---
```
| Scope Filter | Page Included? |
|--------------|----------------|
| `all` | Yes (always) |
| `developers` | Yes |
| `managers` | Yes |
| `sre` | No |
Pages with empty `scopes: []` are included in `all` scope only.
## Mixed Content Detection
Per ADR-0009, pages with scope-conditional content are detected by syntax:
```markdown
---
scopes: [developers, managers]
---
# Getting Started
{{#scope developers}}
Install via npm: `npm install our-sdk`
{{/scope}}
{{#scope managers}}
Contact sales for enterprise onboarding.
{{/scope}}
```
The presence of `{{#scope ...}}` triggers per-scope HTML generation, regardless of the `scopes` array.
## Error Messages
Provide actionable error messages:
```
Error: Failed to parse frontmatter in 'src/admin/config.md'
1 | ---
2 | auth:
3 | access: roles
4 | rols: [admin] # <-- typo: 'rols' should be 'roles'
5 | ---
Unknown field 'rols'. Did you mean 'roles'?
```
## Consequences
### Positive
- Type-safe frontmatter access in Rust
- Build-time validation catches errors early
- JSON Schema enables IDE autocompletion
- Unknown fields preserved for extensibility
### Negative
- Must maintain schema and Rust types in sync
- Frontmatter parsing adds build overhead
- YAML dependency adds to binary size
### Mitigation
- Generate Rust types from JSON Schema (build.rs)
- Cache parsed frontmatter by file hash
- Consider optional serde_yaml feature flag
## Alternatives Considered
### TOML Frontmatter
Use TOML instead of YAML.
**Rejected** because:
- YAML is standard for Hugo, Jekyll, MDBook-like tools
- Users expect YAML in markdown frontmatter
- Better multi-line string support in YAML
### Inline Comments
Use HTML comments for metadata.
**Rejected** because:
- Non-standard, confusing
- Harder to validate
- Can't leverage YAML tooling
### External Metadata File
Separate `metadata.yaml` per chapter.
**Rejected** because:
- Splits related information
- More files to manage
- Breaks single-file-per-chapter model
## References
- [YAML 1.2 Specification](https://yaml.org/spec/1.2.2/)
- [JSON Schema 2020-12](https://json-schema.org/specification.html)
- [serde_yaml](https://docs.rs/serde_yaml/)
- [ADR-0008: Authentication/Authorization Separation](./0008-authentication-authorization-separation.md)
- [ADR-0009: Audience Scopes](./0009-audience-scopes.md)
Contributor guide
Assessment
This issue has not been assessed yet.