Expand description
§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.
[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: 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
- 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 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(())
}{# templates/base.html #}
<!doctype html>
<title>{{ page.title }}</title>
<body>{% block content %}{% endblock %}</body>{# 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:
[
{ "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
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 arrayTwo 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.
§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 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.
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:
{# templates/page.html #}
<p>Reading time: {{ page.extensions.reading_time.estimated_minutes }} minutes</p>
<article>{{ page.content | safe }}</article>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-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.
Structs§
- Builder
- Configures and runs a Markdown → HTML build.
- Watcher
- Incremental, mtime-polling rebuild state produced by
Builder::watch.
Enums§
- DocError
- Errors that can occur while building a Markdown → HTML site.
Traits§
- Markdown
Analyzer - A Markdown analyzer that extracts metadata from the body for template rendering.
- Markdown
Processor - A Markdown processor that transforms the raw body before title/template resolution.