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.7"
# Sanitization is on by default. For a trusted-content, leaner build:
= { = "0.7", = false }
# Optional: readability metrics (mini-litmus integration)
= { = "0.7", = ["litmus"] }
# Optional: BibTeX citations and footnotes (mini-cite integration)
= { = "0.7", = ["cite"] }
# Optional: full YAML frontmatter (otherwise a restricted built-in parser is used)
= { = "0.7", = ["frontmatter-yaml"] } # not yet implemented
# Optional: mini-err integration (DocError → mini_err::Error)
= { = "0.7", = ["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.
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.
pretty_urls(false) (default) |
pretty_urls(true) |
|
|---|---|---|
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 trailing slash is load-bearing. A directory index requested without one is not
that page's canonical URL: servers answer it with a 301 to the slashed form (mini-static
does, as do nginx, Apache and GitHub Pages), because the unslashed form resolves the
page's own relative links against its parent directory. Advertising the slashed URL is
what makes every link land in one hop rather than two, and keeps one page from having
two addresses. A root index.md under link_base = "/" is served at /, not //.
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 (/guide/), 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:
| 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 |
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
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 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
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.
use ;
use Value;
;
new
.templates
.output
.default_template
.processor
.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.
use ;
use ;
;
new
.templates
.output
.default_template
.analyzer
.build?;
In your template, render the analyzer output:
{# templates/page.html #}
Reading time: {{ page.extensions.reading_time.estimated_minutes }} minutes
{{ page.content | safe }}
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:
= { = "0.7", = ["litmus"] }
use ;
new
.templates
.output
.default_template
.analyzer // Add readability metrics
.build?;
Access the metrics in your template:
{# templates/page.html #}
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
Reading time: {{ page.extensions.litmus.estimated_reading_time_minutes | round(1) }} minutes
{{ page.content | safe }}
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:
= { = "0.7", = ["cite"] }
use ;
new
.templates
.output
.default_template
.processor // 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:
use ;
let processor = new?.with_style;
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.
| 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.