mini-docs 0.7.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.7"

# Sanitization is on by default. For a trusted-content, leaner build:
mini-docs = { version = "0.7", default-features = false }

# Optional: readability metrics (mini-litmus integration)
mini-docs = { version = "0.7", features = ["litmus"] }

# Optional: BibTeX citations and footnotes (mini-cite integration)
mini-docs = { version = "0.7", features = ["cite"] }

# Optional: full YAML frontmatter (otherwise a restricted built-in parser is used)
mini-docs = { version = "0.7", features = ["frontmatter-yaml"] }  # not yet implemented

# Optional: mini-err integration (DocError → mini_err::Error)
mini-docs = { version = "0.7", 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 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:

[
  { "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.7", 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.7", 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.