mini-docs 0.3.7

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
# 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.3"

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

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

# Optional: mini-err integration (DocError → mini_err::Error)
mini-docs = { version = "0.3", 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.

## 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-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.