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.
[]
= "0.3"
# Sanitization is on by default. For a trusted-content, leaner build:
= { = "0.3", = false }
# Optional: full YAML frontmatter (otherwise a restricted built-in parser is used)
= { = "0.3", = ["frontmatter-yaml"] } # not yet implemented
# Optional: mini-err integration (DocError → mini_err::Error)
= { = "0.3", = ["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
- One responsibility per crate. Parse Markdown, render it through Tera, write HTML files. Not a server, not a docs framework.
- 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.
- Minimal, justified dependencies. Core is
pulldown-cmark+tera(+serde, via Tera) +ammonia(default sanitize). Everything else is flag-gated. - Explicit over implicit. Builder-configured — input dir, template dir, output dir are all passed in, no ambient globals.
- No proc macros in the public API.
- 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)
use Builder;
{# templates/base.html #}
<!doctype html>
{{ page.title }}
{% block content %}{% endblock %}
{# templates/page.html #}
{% extends "base.html" %}
{% block content %}
{{ page.content | safe }}
{% 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:
| 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
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 baretrue/false(parsed as a real JSON boolean — this is what backspinned/draft), or any other unquoted scalar, which is always parsed as a string. There is deliberately no numeric type:version: 2stays 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
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.