Skip to main content

Crate mini_docs

Crate mini_docs 

Source
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: readability metrics (mini-litmus integration)
mini-docs = { version = "0.4", features = ["litmus"] }

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

ApproachContent-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/markdownpartialtight
Runtime Handler✗ — must re-derive all of ittight
Build-time SSG (chosen)✓ — real .html on disk✓ — inherited for freenone

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
        .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

KeyTypeSource
page.contentHTML string (rendered + sanitized)the Markdown body — inject with | safe
page.titlestringfrontmatter title → first # heading → filename
page.frontmattermapevery frontmatter key
page.urlstringnot yet implemented — computed for data.json (below) but not exposed to templates
page.slugstringnot yet implemented
sitemapnot 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 }
]
FieldSourceDefault
idthe .md path relative to input_dir, extension stripped (guide/setup.mdguide/setup)
titlesame resolution as page.title (frontmatter → first heading → filename)
date, updated, version, summaryfrontmatter keys, echoed verbatim (opaque strings — mini-docs never parses or validates date)""
urlid joined under link_base (defaults to / even if link_base isn’t set — every entry needs some URL)
tagsfrontmatter tags: list; non-string items are dropped silently[]
pinnedfrontmatter 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 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 h1h6. 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>

§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.4", 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>

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.

DependencyWhenWhy not std / hand-rolled
pulldown-cmarkalwaysThe 20% we can’t reasonably reimplement — a compliant CommonMark parser.
teraalwaysA real template language; serde-only by default; decoupled from Zola.
serdealways (via tera)Already Tera’s sole default dep; doubles as the frontmatter/context data model.
ammoniadefault (sanitize)Correct HTML sanitization is security-critical and adversarial; escapable via default-features = false.
a yaml parserfrontmatter-yaml onlyOff by default; built-in restricted parser covers the common case.
notifywatch-notify onlyOff by default; polling covers the common case.
syntecthighlight onlyOff by default; client-side highlighting covers the common case.
mini-litmuslitmus onlyOff by default; provides readability metrics and reading-time estimates.
mini-err / mini-logserr / log onlyOptional family integrations.

§Error types

DocError, mirroring StaticError’s shape and its “never leak internals” discipline.

VariantMeaninguser_message()
Frontmattermalformed frontmatter block"invalid frontmatter"
Markdownrender failure"could not render markdown"
TemplateTera load/render failure"template error"
Ioread/write failure"io error"
Escapeoutput path left the output root"output path escaped root"

§mini-err integration (optional, err feature)

DocErrormini_err variantCode
FrontmatterBad400
MarkdownBad400
TemplateBad400
EscapeBad400
IoIo500

§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§

MarkdownAnalyzer
A Markdown analyzer that extracts metadata from the body for template rendering.
MarkdownProcessor
A Markdown processor that transforms the raw body before title/template resolution.