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-01The model

Contexts — dictionaries of templates

One idea drives everything:

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.

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.

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. Fittingly, tilia's own documentation is built with minidoc.

M-02First build

One config, one site

pnpm add -D @epure/minidoc
# content/config.yaml
var:
  site: Marmot Docs
  lang: en
build:
  - output: "{{lang}}/home.html"   # output paths are templates too
    input: |-
      <h1>{{site}}</h1>
// build.mjs
import { run } from "@epure/minidoc"

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

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-03Var kinds

Var kinds — file, dir, list, value

Every var is a template plus, optionally, a source and a transform. A plain string is just a template; a mapping picks a kind by its key.

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 of scalars and scalar lists. The body renders in a child context of that frontmatter, and — for single file vars — the frontmatter also merges into the declaring context, just below its explicit vars (so an explicit var: title: wins). Two files in one block exporting the same name is a conflict and fails loud. Dots are ordinary characters in names, so dotted namespaces like signature.ts are fine.

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). glob (default *.md) selects files; transform overrides the per-file inference. Unlike file vars, items do not export their frontmatter outward — eight chapters would conflict on title; it stays local to each item. An empty match fails loud.

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, outputs, 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 }

Paths — the one exception

Declared paths (base, output, file, dir, copy) are relative to the config file that declares them, anchored at parse time. Paths may contain {{refs}}, but they resolve against plain string vars only — paths must resolve before content loads, so they can never depend on it. Refs can contribute path segments but never move the anchor; absolute paths (/...) pass through untouched.

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

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 item boxes only that item, the rest of the page still renders. 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" })

Design

Three source files, ReScript, plus the dev runner:

  • src/Schema.resSury 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.reswatch 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 day that port is taken, a free one replaces it in the file. Passing port explicitly skips all of this and writes nothing.