mini-docs 0.6.0

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.

[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:

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)

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(())
}
{# 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.

URL style

By default a page is written beside its source and served by name: guide/setup.mdguide/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 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:

[
  { "id": "getting-started", "title": "Getting Started", "date": "2026-07-14",
    "updated": "", "version": "", "url": "/getting-started.html",
    "summary": "", "tags": ["guide"], "pinned": true }
]
Field Source Default
id the .md path relative to input_dir, extension stripped (guide/setup.mdguide/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 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 h1h6, 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 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>

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:

mini-docs = { version = "0.6", features = ["litmus"] }
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:

{# 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:

mini-docs = { version = "0.6", features = ["cite"] }
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:

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.

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.