mini-docs 0.4.5

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
# mini-docs

A minimal, secure **build-time** Markdown → HTML generator for the `mini-*` family.
Point it at a directory of `.md` files and a directory of **Tera** templates; it emits a
mirrored directory of ready-to-serve `.html`. Pairs cleanly with `mini-static`, but
depends on it for nothing.

> Status: M0–M2 (DEV_PLAN.md) implemented — Builder, frontmatter, rendering, sanitize,
> escape guard, clean-URL links, heading anchors, `watch()`, and the mtime render
> cache all exist and are tested. This README still doubles as the plan of record for
> what hasn't landed yet (M3+); sections describing unimplemented features say so.

```toml
[dependencies]
mini-docs = "0.4"

# Sanitization is on by default. For a trusted-content, leaner build:
mini-docs = { version = "0.4", default-features = false }

# Optional: readability metrics (mini-litmus integration)
mini-docs = { version = "0.4", features = ["litmus"] }

# Optional: BibTeX citations and footnotes (mini-cite integration)
mini-docs = { version = "0.4", features = ["cite"] }

# Optional: full YAML frontmatter (otherwise a restricted built-in parser is used)
mini-docs = { version = "0.4", features = ["frontmatter-yaml"] }  # not yet implemented

# Optional: mini-err integration (DocError → mini_err::Error)
mini-docs = { version = "0.4", features = ["err"] }  # not yet implemented — mini-err has no API yet
```

## Philosophy

Converting Markdown to HTML is easy. Producing *ready-to-serve* HTML — correct content
types, real page layouts, clean URLs, no XSS holes, cache-friendly files — is the part
that gets skipped. `mini-docs` does that part, then gets out of the way by writing plain
files that any static server already knows how to serve.

### Why build-time, not a runtime handler?

Evaluated against `mini-static` as the serving layer:

| Approach | Content-type correct? | Free ETag/304/range/reload? | Coupling to mini-static |
|---|---|---|---|
| **Transform** (`Fn(&str, Vec<u8>) -> Vec<u8>`) | ✗ — can't set headers, body stays `text/markdown` | partial | tight |
| **Runtime Handler** || ✗ — must re-derive all of it | tight |
| **Build-time SSG** (chosen) | ✓ — real `.html` on disk | ✓ — inherited for free | **none** |

Build-time wins on every axis that matters. Emitting real `.html` sidesteps the
content-type problem and inherits `mini-static`'s conditional GET, range requests, and
live-reload *for free*. The two crates cooperate **only through the filesystem**.

A runtime mode (for content that can't be rebuilt — wikis, user-supplied Markdown) is a
possible future, deferred and gated on staying filesystem-decoupled. See *Non-goals*.

### Why Tera for templating?

String substitution alone has no shared layout, no nav, no iterating a page set to build
an index. **Tera** is the pick: same author and purpose as Zola's engine, serde-only by
default (glob loading, unicode segmentation, speed features are opt-in), and a standalone
project not welded to Zola. MiniJinja was the only comparably-minimal alternative
(also serde-only); Tera's SSG pedigree settled it.

### Design tenets

1. **One responsibility per crate.** Parse Markdown, render it through Tera, write HTML
   files. Not a server, not a docs framework.
2. **Secure by default.** Rendered Markdown is sanitized before it ever reaches a
   template, and the template layer must not un-escape it back into a hole.
3. **Minimal, justified dependencies.** Core is `pulldown-cmark` + `tera` (+ `serde`, via
   Tera) + `ammonia` (default sanitize). Everything else is flag-gated.
4. **Explicit over implicit.** Builder-configured — input dir, template dir, output dir
   are all passed in, no ambient globals.
5. **No proc macros in the public API.**
6. **Composes with mini-static, requires nothing from it.** Zero shared types, zero
   version lockstep — the seam is a directory of files.

## Target API (build-time)

```rust,ignore
use mini_docs::Builder;

fn main() -> Result<(), mini_docs::DocError> {
    Builder::new("./docs")           // input dir of .md
        .templates("./templates")    // dir of Tera templates
        .output("./public")          // output dir of .html (mirrors structure)
        .default_template("page.html")
        .link_base("/")              // rewrite [x](x.md) -> /x
        .data_json("data.json")      // optional: write a page index (see below)
        .build()?;                   // walk, render md, sanitize, render Tera, write
    Ok(())
}
```

```html
{# templates/base.html #}
<!doctype html>
<title>{{ page.title }}</title>
<body>{% block content %}{% endblock %}</body>
```

```html
{# templates/page.html #}
{% extends "base.html" %}
{% block content %}
  <article>{{ page.content | safe }}</article>
{% endblock %}
```

The `| safe` is mandatory and load-bearing — see *Security*. A page selects its template
via a `template:` frontmatter key, falling back to `default_template`.

## Template context

| Key | Type | Source |
|---|---|---|
| `page.content` | HTML string (rendered + sanitized) | the Markdown body — **inject with `\| safe`** |
| `page.title` | string | frontmatter `title` → first `#` heading → filename |
| `page.frontmatter` | map | every frontmatter key |
| `page.url` | string | *not yet implemented* — computed for `data.json` (below) but not exposed to templates |
| `page.slug` | string | *not yet implemented* |
| `site` | map | *not yet implemented* — no builder-supplied globals mechanism exists |

**A `pages` template variable (for an in-template index or nav) isn't implemented.**
Exposing it would require a true two-pass build: gather every page's metadata first,
then render, since page A's template may list page B. What *is* implemented instead —
and solves the same "I need every page's metadata in one place" problem for an
external consumer rather than a template — is `data.json`, below.

## `data.json` page index

Opt in with `.data_json("data.json")` (a filename relative to `output_dir`; off by
default). `build()` — and `watch()`'s `Watcher::tick()`, when a `.md` file was added,
removed, or modified — writes a flat JSON array, one object per non-draft page:

```json
[
  { "id": "getting-started", "title": "Getting Started", "date": "2026-07-14",
    "updated": "", "version": "", "url": "/getting-started",
    "summary": "", "tags": ["guide"], "pinned": true }
]
```

| Field | Source | Default |
|---|---|---|
| `id` | the `.md` path relative to `input_dir`, extension stripped (`guide/setup.md``guide/setup`) ||
| `title` | same resolution as `page.title` (frontmatter → first heading → filename) ||
| `date`, `updated`, `version`, `summary` | frontmatter keys, echoed verbatim (opaque strings — mini-docs never parses or validates `date`) | `""` |
| `url` | `id` joined under `link_base` (defaults to `/` even if `link_base` isn't set — every entry needs *some* URL) ||
| `tags` | frontmatter `tags:` list; non-string items are dropped silently | `[]` |
| `pinned` | frontmatter `pinned:` (a real boolean — see *Frontmatter*, below) | `false` |

`draft: true` in a page's frontmatter excludes it from **both** `data.json` and the
HTML build entirely. Flipping a page to `draft: true` after it's already been
published does not delete its existing `.html` output — `build()` has no
orphan-removal pass in general (deleting a `.md` file doesn't clean up its old output
either); this is a known, pre-existing limitation, not draft-specific.

Field key order in the JSON is cosmetic — `serde_json`'s default `Map` serializes
alphabetically (no `indexmap`/`preserve_order` dependency pulled in to change that).
Array order matches the input walk (alphabetical by path); `data.json` doesn't sort by
`date` or `pinned` — that's for the consumer (search index, TOC, recent-items list) to
do, keeping mini-docs a plain data source rather than a second opinion on presentation.

## Pipeline

```text
Builder::build()
  ├── load Tera templates from templates_dir
  ├── walk(input_dir)                         ← bounded: skips symlinks, no cycles
  ▼  for each .md file
  ├── split_frontmatter(bytes)                ← "---\n … \n---\n" delimiter
  ├── render_markdown(body)                   ← pulldown-cmark → html string
  │     └── rewrite_links(link_base)
  ├── sanitize(html)                          ← ammonia; ON by default, BEFORE `safe`
  ├── build_context(page, site)
  ├── tera.render(template, &context)
  └── write(output_dir.join(mirrored_path).with_extension("html"))
        └── guard: resolved path must stay inside output_dir
  (after the loop) if data_json is set → rebuild_data_json(): re-walk, collect every
  non-draft page's (id, title, url, frontmatter fields), write the JSON array
```

Two bounds from the reliability rules: **bounded traversal** (symlinks not followed, no
cycles), **no path escape** (joined output path canonicalized and verified to start with
the output root — the write-side mirror of `mini-static`'s `resolve()`).

## Frontmatter

`---`-delimited YAML-style block. `title`/`template` are special-cased; every key
populates `page.frontmatter.*`. Parsed into a serde value (Tera already depends on
`serde`) via a built-in restricted parser by default — `serde_yaml` is archived and
fails the 5-year maintainability test, so full arbitrary YAML is opt-in behind
`frontmatter-yaml` (not yet implemented). The restricted grammar:

- `key: value` — one per line, no nesting.
- A quoted string (`"..."`), an inline list (`[a, b, "c"]`), a bare `true`/`false`
  (parsed as a real JSON boolean — this is what backs `pinned`/`draft`), or any other
  unquoted scalar, which is always parsed as a string. There is deliberately no
  numeric type: `version: 2` stays the string `"2"`, since mini-docs never interprets
  a frontmatter value arithmetically.

## Security & sanitization

`pulldown-cmark` passes raw inline HTML through untouched; Tera auto-escapes `.html`
output by default. The one invariant that matters:

**Sanitize → then mark safe → then render.** Ammonia runs on the rendered HTML *before*
it enters the Tera context, so by the time a template sees `page.content` it is already
clean; `{{ page.content | safe }}` only ever marks already-sanitized content. Marking
unsanitized content safe re-opens the exact hole `| safe` exists to let through. Only the
body is sanitized — templates are author-controlled trusted input.

Sanitization is a default feature; opting out (`default-features = false`, a
compile-time choice — there is no per-build `.raw_html(true)` escape hatch) is
explicit, for callers who have measured trusted-content input. The default build
pulling in `ammonia`'s tree is the right trade — minimal-deps is a guide against
*uncontrolled* build trees, not a mandate to weaken a security default to keep a
dependency count low.

`sanitize_html` also allowlists `id` as a generic attribute beyond ammonia's default
policy (which only permits it on `<a>`) — heading-anchor slugs (see *Template
context*) rely on `id` surviving on `h1`–`h6`. `id` is inert (no script-execution
vector), so this widens *which elements* keep an id, not *what an id can contain*.

A required MVP test feeds an XSS payload through the full pipeline **including**
`{{ page.content | safe }}` and asserts the payload is absent from the written file.

## Syntax highlighting

The renderer emits `<pre><code class="language-rust">` and stops — highlighting is
delegated to a client-side library chosen by the template author. Server-side
highlighting via `syntect` is deferred, flag-gated (`highlight`), not initial surface.

## Composition with mini-static

```text
a caller driving Builder::watch()          mini-static (debug)
  │ poll .md AND templates/ mtimes        │ poll output dir every 500ms
  │ on change → re-render → write .html ──┼─→ notices new .html
  │                                        │ → fires its own SSE reload
  └────────── filesystem is the only seam ─┘
```

`Builder::watch()` and `Watcher::tick()` are library primitives, not a shipped CLI —
`tick()` performs one poll-and-rebuild cycle and returns which `.md` paths it rebuilt;
a caller drives the cadence (a blocking loop with `std::thread::sleep`, a GUI's idle
callback, a test). Templates are inputs too — a base-layout change is treated as
"rebuild all dependents" (mini-docs doesn't parse Tera's `{% extends %}` graph, so it
conservatively rebuilds every page rather than risking a stale one), not "rebuild one
page." Watch uses mtime polling, consistent with `mini-static`'s own poller; a
`notify`-based watcher behind `watch-notify` for large trees is not yet implemented.

## Extensions

Extend the build pipeline by registering **processors** and **analyzers** to transform
Markdown and extract metadata.

### MarkdownProcessor

A processor transforms the raw Markdown body before title/template resolution. Processors
run in registration order; each sees the output of the previous. Use a processor to
rewrite links, inject content, or normalize syntax before rendering.

```rust,ignore
use mini_docs::{Builder, MarkdownProcessor, DocError};
use serde_json::Value;

struct MyProcessor;

impl MarkdownProcessor for MyProcessor {
    fn process(&self, body: &str, frontmatter: &Value) -> Result<String, DocError> {
        // Transform body based on frontmatter or a fixed rule
        Ok(format!("{body}\n\n*processed*"))
    }

    fn name(&self) -> &str {
        "my_processor"  // unique identifier; must be [a-z0-9_]
    }
}

Builder::new("./docs")
    .templates("./templates")
    .output("./public")
    .default_template("page.html")
    .processor(MyProcessor)
    .build()?;
```

### MarkdownAnalyzer

An analyzer extracts metadata from the (processed) Markdown body and exposes it to
templates under `page.extensions.<name>`. Use an analyzer to compute word counts,
reading time, headings, or any other statistic.

```rust,ignore
use mini_docs::{Builder, MarkdownAnalyzer, DocError};
use serde_json::{json, Value};

struct ReadingTimeAnalyzer;

impl MarkdownAnalyzer for ReadingTimeAnalyzer {
    fn analyze(&self, body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
        let word_count = body.split_whitespace().count();
        let minutes = std::cmp::max(1, word_count / 200);
        Ok(json!({ "estimated_minutes": minutes }))
    }

    fn name(&self) -> &str {
        "reading_time"
    }
}

Builder::new("./docs")
    .templates("./templates")
    .output("./public")
    .default_template("page.html")
    .analyzer(ReadingTimeAnalyzer)
    .build()?;
```

In your template, render the analyzer output:

```html
{# templates/page.html #}
<p>Reading time: {{ page.extensions.reading_time.estimated_minutes }} minutes</p>
<article>{{ page.content | safe }}</article>
```

### Built-in analyzers

**Readability metrics** (enabled with `features = ["litmus"]`): The `mini-litmus` crate
provides a `LitmusAnalyzer` that computes readability scores, word counts, and reading
time estimates. Enable the `litmus` feature to use it:

```toml
mini-docs = { version = "0.4", features = ["litmus"] }
```

```rust,ignore
use mini_docs::{Builder, LitmusAnalyzer};

Builder::new("./docs")
    .templates("./templates")
    .output("./public")
    .default_template("page.html")
    .analyzer(LitmusAnalyzer)  // Add readability metrics
    .build()?;
```

Access the metrics in your template:

```html
{# templates/page.html #}
<p>
  Readability: {{ page.extensions.litmus.readability_scores.flesch_reading_ease | round }}/100 ease,
  {{ page.extensions.litmus.readability_scores.flesch_kincaid_grade_level | round(1) }} grade level
</p>
<p>Reading time: {{ page.extensions.litmus.estimated_reading_time_minutes | round(1) }} minutes</p>
<article>{{ page.content | safe }}</article>
```

### Built-in processors

**BibTeX citations** (enabled with `features = ["cite"]`): The `mini-cite` crate provides
a `CiteProcessor` that rewrites Pandoc-style `[@key]` citations into numbered footnote
references, resolved against a directory of `.bib` files, and appends the matching
footnote block to the page:

```toml
mini-docs = { version = "0.4", features = ["cite"] }
```

```rust,ignore
use mini_docs::{Builder, CiteProcessor};

Builder::new("./docs")
    .templates("./templates")
    .output("./public")
    .default_template("page.html")
    .processor(CiteProcessor::new("./bib")?)  // Resolve [@key] against ./bib/*.bib
    .build()?;
```

`Small things matter [@smith2020].` renders a `<sup>` reference linked to a footnote
list at the end of the page. Citations inside code blocks and inline code spans are
left literal; a citation naming a key no `.bib` file defines aborts the build.

The bibliography is read once, when the processor is constructed, so a malformed or
ambiguous `.bib` directory fails before any page is built. A `.bib` file edited during
a `Watcher` session is not picked up until the session restarts — `Watcher` rebuilds on
`.md` and template changes only.

**Naming contract:** Each processor and analyzer `name()` must be non-empty, ASCII-only,
and a valid Tera identifier (`[a-z0-9_]+`). Duplicate names across the same kind
(e.g., two processors with the same name) produce a `DocError::Extension` at build
time before any page is written. Processor and analyzer names are scoped separately —
a processor and analyzer may share the same name without conflict.

**Error handling:** Errors from processors or analyzers abort the entire build, just
like template errors. Errors should be prefixed with the extension's `name()` for
clarity, e.g. `"litmus: word count failed"`.

## Dependency budget

Production target: ≤ 5 direct deps.

| Dependency | When | Why not std / hand-rolled |
|---|---|---|
| `pulldown-cmark` | always | The 20% we can't reasonably reimplement — a compliant CommonMark parser. |
| `tera` | always | A real template language; serde-only by default; decoupled from Zola. |
| `serde` | always (via `tera`) | Already Tera's sole default dep; doubles as the frontmatter/context data model. |
| `ammonia` | default (`sanitize`) | Correct HTML sanitization is security-critical and adversarial; escapable via `default-features = false`. |
| a yaml parser | `frontmatter-yaml` only | Off by default; built-in restricted parser covers the common case. |
| `notify` | `watch-notify` only | Off by default; polling covers the common case. |
| `syntect` | `highlight` only | Off by default; client-side highlighting covers the common case. |
| `mini-litmus` | `litmus` only | Off by default; provides readability metrics and reading-time estimates. |
| `mini-cite` | `cite` only | Off by default; provides BibTeX-backed citations and footnotes. |
| `mini-err` / `mini-logs` | `err` / `log` only | Optional family integrations. |

## Error types

`DocError`, mirroring `StaticError`'s shape and its "never leak internals" discipline.

| Variant | Meaning | `user_message()` |
|---|---|---|
| `Frontmatter` | malformed frontmatter block | `"invalid frontmatter"` |
| `Markdown` | render failure | `"could not render markdown"` |
| `Template` | Tera load/render failure | `"template error"` |
| `Io` | read/write failure | `"io error"` |
| `Escape` | output path left the output root | `"output path escaped root"` |

### `mini-err` integration (optional, `err` feature)

| DocError | mini_err variant | Code |
|---|---|---|
| `Frontmatter` | `Bad` | 400 |
| `Markdown` | `Bad` | 400 |
| `Template` | `Bad` | 400 |
| `Escape` | `Bad` | 400 |
| `Io` | `Io` | 500 |

## Non-goals

- **Not a runtime renderer** (for now) — see *Why build-time* above.
- **Not a docs framework.** No baked-in theming, nav conventions, or plugin system.
- **Not a Tera fork or wrapper API.** We embed Tera and expose a context; swap-ability
  is a non-goal — committing to one engine is what lets the crate stay small.
- **Not a production web server.** That's `mini-static`'s job.

## MSRV

Target **1.75**, matching `mini-static`. Confirm Tera's current MSRV before committing —
if it exceeds 1.75, that forces a family-wide decision, and any bump is itself a breaking
change.