# 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.6"
# Sanitization is on by default. For a trusted-content, leaner build:
mini-docs = { version = "0.6", default-features = false }
# Optional: readability metrics (mini-litmus integration)
mini-docs = { version = "0.6", features = ["litmus"] }
# Optional: BibTeX citations and footnotes (mini-cite integration)
mini-docs = { version = "0.6", features = ["cite"] }
# Optional: full YAML frontmatter (otherwise a restricted built-in parser is used)
mini-docs = { version = "0.6", features = ["frontmatter-yaml"] } # not yet implemented
# Optional: mini-err integration (DocError → mini_err::Error)
mini-docs = { version = "0.6", 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:
| **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.html
.data_json("data.json") // optional: write a page index (see below)
.pretty_urls(true) // optional: serve /x instead of /x.html (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 %}
```
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
| `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.
## URL style
By default a page is written beside its source and served by name: `guide/setup.md` →
`guide/setup.html`, at `/guide/setup.html`. `.pretty_urls(true)` writes it as a
directory index instead — `guide/setup/index.html`, served at `/guide/setup` — which
needs no server support beyond serving `index.html` for a directory request, something
every static host does.
| `guide/setup.md` is written to | `guide/setup.html` | `guide/setup/index.html` |
| and served at | `/guide/setup.html` | `/guide/setup` |
| `guide/index.md` is written to | `guide/index.html` | `guide/index.html` |
| and served at | `/guide/index.html` | `/guide` |
| `[Setup](setup.md)` becomes | `/setup.html` | `/setup` |
| `data.json` `url` | `/guide/setup.html` | `/guide/setup` |
The rule both styles hold to is that **every URL mini-docs advertises names a file
mini-docs wrote** — the `data.json` `url`, the rewritten link, and the output path all
come out of one decision (`route.rs`) rather than being derived separately.
A source file named `index.md` is already its directory's index, so it is never nested
a second time: `guide/index.md` is written to `guide/index.html` under both styles,
never `guide/index/index.html`. Under `pretty_urls` it is served as the directory
itself, which is how a section landing page works.
Changing the setting on an existing site leaves the previous style's output behind —
nothing deletes files it didn't write this run. Clear `output_dir` when switching, or
both URLs stay live and serve the same page.
An extensionless *file* (`guide/setup` with no extension) is deliberately not an
option: it carries no content type, so most hosts send it as a download rather than
rendering it.
## `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.html",
"summary": "", "tags": ["guide"], "pinned": true }
]
```
| `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` | the page's own address under `link_base` — `/getting-started.html`, or `/getting-started` with `pretty_urls` on (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` and `class` as generic attributes beyond ammonia's
default policy (which permits `id` only on `<a>` and strips `class` outright) —
heading-anchor slugs (see *Template context*) rely on `id` surviving on `h1`–`h6`, and
extension-emitted markup such as `CiteProcessor`'s footnotes is unstylable without
`class`. Both are inert (no script-execution vector, no URL), so this widens *which
elements* keep them, not *what they can contain*. The cost is that a content author can
name any class the site's stylesheet defines — a styling concern, not a security one.
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.6", 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.6", 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.
The emitted markup carries classes (`footnote-ref`, `footnotes`, `footnote-back`, …,
following Pandoc's names) which survive sanitization, so footnotes can be styled by
class rather than by attribute selector. `CiteStyle` — re-exported from `mini-cite` —
renames any of them and sets the back-link's label:
```rust,ignore
use mini_docs::{CiteProcessor, CiteStyle};
let processor = CiteProcessor::new("./bib")?.with_style(
CiteStyle::default()
.reference_class("citation-marker")
.backlink_label("↑"),
);
```
Setting a class to `""` emits that element with no `class` attribute at all, for sites
styling structurally (`li[id^="fn-"]`) instead.
**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.
| `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.
| `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)
| `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.