aRustyDev / aRustyDev/mdbook-htmx

docs(adr): ADR-0013: Frontmatter Schema and Validation

Open
#21 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

# 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

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.