# 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 {{title}}
{{main}}
``` ```yaml # content/config.yaml var: site: Marmot Docs lang: en layout: file: layout.html build: - output: index.html input: "{{layout}}" var: title: "{{site}}" main: "

{{site}}

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

{{title}}

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

Home

``` 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: '
  • {{title}}
  • ' 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.