# minidoc — full documentation
> minidoc is a small documentation website generator from the épure toolset. YAML configs declare variables and build outputs; contexts are dictionaries of templates; you inherit formulas, not values. This site is built with it.
## 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](#dev) — and the site takes shape as the files land.
```sh
pnpm add -D @epure/minidoc
```
The base layout, its config, and a `run`:
```html
"
- output: styles.css
input: { copy: styles.css }
```
```ts
// 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](https://github.com/epuremethod/minidoc/tree/main/docs).
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](#model). `file`,
`dir`, `list`, and `value` vars grow the site out of content files — see
[Vars](#vars).
## 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: "
{{title}}
"` 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.
```yaml
# baseConfig.yaml
var:
layout: "
```
Substitution is plain name lookup — no filters, no expressions. Undefined
names and reference cycles [fail loud, sited](#errors).
### 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:
```yaml
# base.yaml
var:
layouts:
cv: common-cv
letter: common-letter
```
```yaml
# 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](#vars); 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](https://tiliajs.dev) 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.
## 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](#model)), so
each field of a kind can be overridden on its own.
### file
Loads its template from an html/md file:
```yaml
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:
```yaml
var:
email:
file: email.md
optional: true
template: '
{{subject}}
{{body}}'
```
### dir
Loads a folder of content files and renders each through an `each` template —
the building block for a guide or an API reference:
```yaml
var:
toc:
dir: guide
each: '
'
chapters:
dir: guide # the same folder, a second view
each: '{{body}}'
```
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:
```yaml
var:
applications:
dirs: candidatures # candidatures/2026-01-acme/cv.md, ...
order: desc # newest folder first
each: '
{{folder.name}}
{{cv.role}}
{{email}}
'
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
`join`ed items as ``{{body}}``. An empty list renders nothing, wrapper
included:
```yaml
var:
refsBlock:
list: refs
each: '{{item}}'
join: ", "
template: '
Reference: {{body}}
'
```
### 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:
```yaml
var:
signatureHtml:
value: "{{signature.ts}}"
transform: typescript
entries:
dir: api
each: "{{signatureHtml}}{{body}}" # per-item signature, one declaration
```
## 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:
```yaml
build:
- output: "{{slug}}.html" # slug from the file's frontmatter
input:
file: content/home.md
- output: guide.html
input:
dir: content/guide
each: "{{body}}"
```
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:
```yaml
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.
```yaml
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:
```yaml
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](#api).
## 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.
```ts
await run({ glob: "content/**/config.yaml", inlineErrors: dev })
```
## run, transforms, filesystems
```ts
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](#filesystems); 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](#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:
```ts
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:
```ts
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](https://github.com/DZakh/sury) schemas parsing
YAML configs and frontmatter into tagged variants; path anchoring, and the
remembered port.
- `src/Minidoc.res` — contexts ([tilia](https://tiliajs.dev) 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.
## Watch, rebuild, live reload
`dev` builds the site, watches the project, rebuilds what changed, and serves
the output with live reload:
```ts
// 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.
```ts
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:
```ts
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:
```yaml
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.