minidoc · épure

M-00 · A small documentation website generator

Documentation, generated from one idea.

minidoc discovers YAML configs, renders every {{var}} reference, and writes each resolved input to its output path. A context is a dictionary of templates; a child context is the parent's templates merged with its own — own wins. Everything renders in its nearest context. That is the whole tool — this page included: the site you are reading is built by minidoc from one config.

BASE CONFIG INHERITED FORMULAS CONFIG VAR THE SITE'S CONTEXT BUILD VAR ONE PAGE'S CONTEXT FRONTMATTER MOST LOCAL · OWN WINS RESOLVES IN THE NEAREST CONTEXT {{title}}
Fig. M-00 — a render, in section · contexts merge, own wins
M-01Getting started

One config, one site

Starting a documentation website

  1. Design. Find a design, or vibe-code one. The look is yours — HTML, CSS, fonts. minidoc fills in the content. Start from a base index.html layout — one HTML shell, shared by every page.
  2. Install. Add @epure/minidoc, write a config and a run, as below.
  3. Ask an agent to write the docs. At minimum give it the list of pages you want, and how the markdown sources should be organised — one file per concept, one file per method, one file per chapter. Fix the shape first; the agent fills the files.
  4. Dev server. dev() watches, rebuilds, and live-reloads — see Dev — and the site takes shape as the files land.
pnpm add -D @epure/minidoc

The base layout, its config, and a run:

<!-- content/layout.html — the base index.html layout -->
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main>{{main}}</main>
</body>
</html>
# content/config.yaml
var:
  site: Marmot Docs
  lang: en
  layout:
    file: layout.html
build:
  - output: index.html
    input: "{{layout}}"
    var:
      title: "{{site}}"
      main: "<h1>{{site}}</h1>"
  - output: styles.css
    input: { copy: styles.css }
// build.mjs
import { run } from "@epure/minidoc"

await run({ glob: "content/**/config.yaml" })

This site is the worked example — layout, pages, config, run, and dev server in the docs source.

minidoc discovers YAML configs by glob. Each config declares variables and build outputs; every {{var}} reference is rendered and each resolved input is written to its output path.

Runs update declared outputs in place and never clean old output first, so a live server keeps serving the previous files until replacements are written.

base inherits templates from another config — see Model. file, dir, list, and value vars grow the site out of content files — see Vars.

M-02The model

Contexts — dictionaries of templates

One idea drives everything:

A context is a tree of templates. A child context is the parent's tree merged with its own, leaf by leaf — own wins. Everything renders in its nearest context.

A base config, the config's var, a build's var, a content file's frontmatter: each is one layer, merged into the next context. Precedence is merge order.

You inherit formulas, not values. A base declaring layout: "<h1>{{title}}</h1>" does not hand you a rendered string — the template becomes yours and resolves against your title, like copying a spreadsheet: the formulas come along and recompute against your cells.

# baseConfig.yaml
var:
  layout: "<h1>{{title}}</h1>"
# config.yaml
base: baseConfig.yaml
build:
  - var: { title: Home }
    output: home.html
    input: "{{layout}}"        # -> <h1>Home</h1>

Substitution is plain name lookup — no filters, no expressions. Undefined names and reference cycles fail loud, sited.

Nested vars

A var block — or a frontmatter block — nests: {{layouts.cv}} reads the cv key of the layouts group. A dotted key is the same var spelled flat, so layouts.cv: x and layouts: { cv: x } are one leaf (defining it twice in one layer fails loud). Layers merge leaf by leaf, so a page overrides one key and inherits the rest:

# base.yaml
var:
  layouts:
    cv: common-cv
    letter: common-letter
# foo.md frontmatter
---
layouts:
  cv: this-cv          # {{layouts.letter}} is still common-letter
---

What a group is is decided when it is read, after every layer has merged: a group holding a file, dir, dirs, list or value key renders as that kind; any other group is a namespace, and reading it whole fails loud. So a page can override just layouts.cv.file and keep the base's template and transform.

Escaping a reference

To emit a reference as literal text, backtick-quote the name: {{`name`}} outputs {{name}} without evaluation, and the name needs no var. Only the quotes are removed, so the spacing you wrote survives — ${{ `github.sha` }} outputs ${{ github.sha }}, which is what makes the escape usable for documenting templating languages of your own.

What comes out is text, not a template: the literal stays literal however far it travels — through an outer var, a file var importing another, a dir or list item, a transform. A rendered result is never re-rendered, so an escape unwraps exactly once, where it was written.

Under the hood

Each context is a tilia carve: every var is a lazy, cached, dependency-tracked computed. Files load on demand: a render that needs a file not read yet stalls, the read runs, and the render resumes — so a path may depend on anything, even another file's frontmatter. Fittingly, tilia's own documentation is built with minidoc.

M-03Var kinds

Var kinds — file, dir, dirs, list, value

Every var is a template plus, optionally, a source and a transform. A plain string is just a template; a group holding one of the keys below renders as that kind. The key is read after layers merge (see nested vars), so each field of a kind can be overridden on its own.

file

Loads its template from an html/md file:

var:
  intro:
    file: content/intro.md       # markdown -> html, inferred from extension
  snippet:
    file: content/raw.md
    transform: none              # explicit override of the inference

The transform is inferred from the extension (.md/.markdown -> md, .html/.htm -> none); an unknown extension without an explicit transform fails loud. {{refs}} in the body render first, then the transform runs — so a var can inject markdown that gets rendered.

A content file may start with a YAML frontmatter block — scalars, lists and nested groups. The body renders in a child context of that frontmatter, and the declaring context reads it under the var's name: {{intro.title}} is the title of the file intro loads — usable even in an output path. Two files can share frontmatter names without conflict, each under its own var.

A missing file fails loud. optional: true renders it as an empty string instead (and it exports no frontmatter). An optional template wraps the rendered body as {{body}}, in the file's frontmatter context; like a list wrapper, it renders nothing when the body is empty — a missing optional file, or one with only frontmatter or whitespace:

var:
  email:
    file: email.md
    optional: true
    template: '<section><h3>{{subject}}</h3>{{body}}</section>'

dir

Loads a folder of content files and renders each through an each template — the building block for a guide or an API reference:

var:
  toc:
    dir: guide
    each: '<li><a href="#{{slug}}">{{title}}</a></li>'
  chapters:
    dir: guide                   # the same folder, a second view
    each: '<section id="{{slug}}">{{body}}</section>'

Each item's each renders in a child context of the file's frontmatter plus its rendered content as {{body}}. Items join with newlines, in filename order (prefix files 01-intro.md to control it); order: desc reverses it, so date-prefixed files list newest first. glob (default *.md) selects files; a glob with a / reaches into subfolders (*/cv.md one level down, **/cv.md any depth), ordered by path. transform overrides the per-file inference; join (default a newline) sits between items. Unlike file vars, items do not export their frontmatter outward — eight chapters would conflict on title; it stays local to each item. Each item also sees its file name: {{file.name}} (01-intro.md), {{file.stem}} (01-intro) and {{file.dir}}, the name of its parent folder. An empty match fails loud; optional: true accepts it (or a missing folder) and renders nothing.

dirs

Iterates over the subfolders of a folder: one item per subfolder, rendered through each. The item's own var block loads in each subfolder — its file and dir paths are relative to that subfolder — so one row can use several files of the folder:

var:
  applications:
    dirs: candidatures           # candidatures/2026-01-acme/cv.md, ...
    order: desc                  # newest folder first
    each: '<tr><td>{{folder.name}}</td><td>{{cv.role}}</td><td>{{email}}</td></tr>'
    var:
      cv: { file: cv.md }
      lettre: { file: lettre.md }
      email: { file: email.md, optional: true }

Each item sees {{folder.name}}, also usable in its paths (file: "{{folder.name}}.md"). The frontmatter of its files reads under their names — {{cv.role}}, {{lettre.company}} — and stays local to the item. Folders list in name order; order: desc reverses it. each is required. A missing file in any folder fails loud (unless that var is optional); an empty or missing folder fails loud unless optional: true, which renders nothing.

list

Renders a scalar list (from config or frontmatter) through an item template. each sees the scalar as {{item}}; an optional template wraps the joined items as {{body}}. An empty list renders nothing, wrapper included:

var:
  refsBlock:
    list: refs
    each: '<a href="./api.html#{{item}}">{{item}}</a>'
    join: ", "
    template: '<p>Reference: {{body}}</p>'

value

Renders an inline template through a named transform. Because you inherit formulas, a value var declared once re-renders wherever it is used — inside a dir's each it sees that item's frontmatter:

var:
  signatureHtml:
    value: "{{signature.ts}}"
    transform: typescript
  entries:
    dir: api
    each: "{{signatureHtml}}{{body}}"   # per-item signature, one declaration
M-04Build entries

Build entries — inputs, pages, copies

A build's input is a template string, or a file/dir mapping behaving like the matching var kind. A file input's frontmatter merges in as the least local layer — usable even in the output path:

build:
  - output: "{{slug}}.html"    # slug from the file's frontmatter
    input:
      file: content/home.md
  - output: guide.html
    input:
      dir: content/guide
      each: "<section>{{body}}</section>"

An input with a copy key copies a file or directory (recursively) without reading or rendering its content. The paths may contain {{refs}}; the copied bytes never do:

build:
  - output: public/style.css
    input: { copy: assets/style.css }
  - output: public/fonts
    input: { copy: assets/fonts }

Pages — one output per file

pages fans a build entry out over a folder: one output per matched file.

var:
  layout:
    file: layout.html
build:
  - pages: { dir: cvs, glob: "*.md" }
    output: "../dist/{{file.stem}}.html"
    var:
      content: "{{page}}"        # the page's rendered body
    input: "{{layout}}"

Each page renders the entry's input in its own child context: the file's frontmatter layers in over the config's vars, under the entry's own var — plus {{page}}, its rendered body, and {{file.name}} / {{file.stem}} / {{file.dir}} (its parent folder's name). All of it is usable in the output path, so a slug: in every frontmatter is optional. A page's frontmatter stays local to that page. dir, glob (default *.md), transform and order work as for a dir var; the input may be a template or a file, not a copy. An empty match fails loud; optional: true accepts it and writes nothing.

A glob with a / reaches into subfolders — one page per folder:

var:
  layouts.cv:                  # same leaf as layouts: { cv: … }
    file: layouts/cv.html
build:
  - pages: { dir: candidatures, glob: "*/cv.md" }
    output: "../dist/{{file.dir}}-cv.html"   # dist/2026-01-acme-cv.html
    input: "{{layouts.cv}}"

Every output path resolves before anything is written. Two outputs resolving to the same path — two pages sharing a slug, two entries — fail loud, naming both; copies into one directory merge and are exempt:

Output path collision: dist/same.html is written by both build[0] (page cvs/a.md) and build[0] (page cvs/b.md)

Paths

Declared paths (base, output, file, dir, dirs, pages, copy) are relative to the layer that declares them: a config's paths to that config's folder, a frontmatter's paths to its content file's folder, a dirs item's vars to that item's subfolder. Paths are templates like any other and may depend on anything — a var, a page's frontmatter, another file's frontmatter — but refs only contribute segments after the anchor: they never move it. Absolute paths (/...) written as such pass through untouched. Wherever a path leads, the Node filesystem only reaches inside its root and the folders it allows.

M-05Errors

Errors — fail loud, sited

Every render error is decorated with its site — the file or build entry it came from — and undefined references in multi-line templates carry a line number:

Undefined variable {{missing}} at line 3 in file content/intro.md
Variable cycle in file content/intro.md: intro -> intro
Undefined variable {{missing}} in build[0] output
Undefined variable {{slug}} in build[0] output (page cvs/b.md)
Undefined variable {{role}} in folder candidatures/acme (var (config.yaml).applications)

The innermost site wins: an error is labeled once, where it happened, and not again on the way out. Line numbers count from the top of the document — the body keeps its place behind the frontmatter, so a custom transform reporting "line 26" points at line 26 of the actual file.

Inline errors for a dev server

By default any error aborts the run. With inlineErrors: true, content errors instead surface as an error box (thin red border, faint red background, class minidoc-error, message HTML-escaped) at their place in the output page, and each is also logged to the console. The blast radius is the nearest content boundary: a failing dir or dirs item boxes only that item, the rest of the page still renders; a failing page boxes only in its own output. Meant for a dev server — the site keeps building and the error shows up where it happens. Output path errors still fail loud even in this mode: a file cannot be written without a path. Leave it off in CI.

await run({ glob: "content/**/config.yaml", inlineErrors: dev })
M-06API

run, transforms, filesystems

import { run } from "@epure/minidoc"
import { apiMd, typescript } from "./transforms.ts"

await run({
  glob: "content/**/config.yaml",
  transform: { apiMd, typescript },
})

Options:

  • glob — required; selects the entry configs.
  • fs — the filesystem; defaults lazily to Node's, rooted at the current directory.
  • transform — named transforms added to or overriding the built-ins.
  • inlineErrors — content errors render in place instead of aborting (see Errors).

Custom transforms are plain (text: string) => string functions. The built-in registry has md (markdown via marked) and none (passthrough) — this site's build overrides md to color fenced YAML.

Filesystems

All I/O goes through one injected FileSystem interface — the only door to the outside world. Inject one for a browser, test, or other non-Node environment; the Node adapter loads lazily only when fs is omitted:

import { run, nodeFs, makeMemoryFileSystem } from "@epure/minidoc"

await run({ fs: makeMemoryFileSystem(files), glob: "**/config.yaml" })
await run({ fs: nodeFs(new URL("./content/", import.meta.url)), glob: "**/config.yaml" })

A custom filesystem implements readFile, writeFile, copy, exists, glob, listFiles and listDirs — the last lists the subfolders a dirs var or a subfolder glob walks.

The Node filesystem is fenced: every read, write, copy and listing must stay inside its root, or the call fails loud. Paths are templates that content can steer — a page's frontmatter may set file: — so the fence is what keeps a page from publishing ~/.ssh/id_rsa or writing outside the project. Folders outside the root that the site really uses are allowed explicitly:

await run({ fs: nodeFs(root, { allow: ["../shared-content"] }), glob: "config.yaml" })

allow entries are relative to the root or absolute. The check is on the path as written (.. collapsed); symlinks are not followed. watch and dev building in-process allow their watch folders; a build script passes its own allow.

Design

Three source files, ReScript, plus the dev runner:

  • src/Schema.res — Sury schemas parsing YAML configs and frontmatter into tagged variants; path anchoring, and the remembered port.
  • src/Minidoc.res — contexts (tilia carves), rendering, the filesystems, run. All I/O goes through the injected filesystem; transforms are injected too.
  • src/Minidoc.resi — the public interface, mirrored by the hand-written src/Minidoc.res.d.mts for TypeScript consumers.
  • src/Dev.res — watch and dev: the watcher, the rebuild loop, the static server. Node-only, and lazily so — every builtin loads through a dynamic import, so importing minidoc in a browser stays safe. No dependency: Node's own recursive fs.watch is the whole watcher.
M-07Development

Watch, rebuild, live reload

dev builds the site, watches the project, rebuilds what changed, and serves the output with live reload:

// src/dev.mjs
import { dev } from "@epure/minidoc"

await dev({ glob: "content/**/config.yaml", build: "src/build.mjs" })

Options:

  • build — the script that calls run(); each rebuild runs it in a fresh process.
  • root — watched recursively; default the current directory.
  • watch — extra paths to watch, files or folders, inside the project or not, for content that lives elsewhere.
  • ignore — never watched; default dist, node_modules, lib, and every dot-name.
  • extensions — what triggers a rebuild; default .md, .yaml, .html, .css and script files.
  • serve — what gets served, relative to root; default dist (dev only).
  • port — fixed; without one, the port remembered in the config (dev only).

A rebuild fires on any matching file under root — new files included — so the unwatched defaults keep a build from triggering itself. One save is several filesystem events, so changes coalesce; a change arriving mid-build queues exactly one more run, however many arrive.

build is what makes an edited transformer take effect: transforms reach run as closures, and no ESM cache hands back a module a file has changed under. Without build, dev calls run in its own process with inlineErrors on — fine for a site with no custom transforms, blind to a transformer edit.

await dev({
  glob: "content/**/config.yaml",
  build: "src/build.mjs",
  watch: ["../docs", "../db/types.yaml"],
})

The served page carries one injected line, an EventSource that reloads it when a build lands. The rest is a static host: /guide/ answers with its index, /guide with guide.html then guide/index.html, so the dev site is the deployed site.

watch is the same runner without the server, for a project that serves its output some other way:

const watcher = await watch({ glob: "content/**/config.yaml", build: "src/build.mjs" })
watcher.stop()

The port

A dev server on a fixed port collides with every other project on the machine. So the port is drawn once, free, and written back to the entry config — under var, like any other scalar:

var:
  site: Marmot Docs
  port: 51234        # written on first launch

The site keeps that address for good: bookmarkable, and {{port}} is an ordinary var a template can print. The config is rewritten through yaml's document API, so comments and layout survive. The port is never replaced: when a dev server already answers on it — the same site, launched twice — dev prints its link and returns; when anything else holds it, dev fails loud. Passing port explicitly skips the remembering and writes nothing.