A small documentation website generator. It discovers YAML configs by glob;
each config declares variables and build outputs. Minidoc renders every
{{var}} reference and writes each resolved input to its output path.
var: # this config's context
site: Marmot Docs
base: baseConfig.yaml # optional: templates inherited from another config
build:
- var: # each build extends the context
title: Home
output: "{{lang}}/home.html" # output paths are templates too
input: |-
<h1>{{site}} - {{title}}</h1>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.
So a base config, the config's var, a build's var, a content file's
frontmatter: each is just a layer merged into the next context. Precedence is
merge order, nothing more.
The one sentence worth memorizing: you inherit formulas, not values. A
base declaring layout: "<h1>{{title}}</h1>" doesn't hand you a rendered
string — the template becomes yours and resolves against your title,
like copying a spreadsheet: you copy the formulas, and they 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>Undefined variables and reference cycles fail loud, naming the build entry
and the variable path. v1 is plain name substitution — no escaping of literal
{{/}}, no filters, no expressions.
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 — tilia all the way down.)
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 inferenceThe 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 declarationA 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 }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.
import { run } from "@epure/minidoc"
import { apiMd, typescript } from "./transforms.ts"
await run({
glob: "content/**/config.yaml",
transform: { apiMd, typescript },
})Custom transforms extend or override the built-in registry (md renders
markdown via marked, none passes through). 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.
Inject a filesystem 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" })Three source files, ReScript:
src/Schema.res— Sury schemas parsing YAML configs and frontmatter into tagged variants; path anchoring.src/Minidoc.res— contexts (tilia carves), rendering, the filesystems,run. All I/O goes through the injectedfilesystem; transforms are injected too.src/Minidoc.resi— the public interface, mirrored by the hand-writtensrc/Minidoc.res.d.mtsfor TypeScript consumers.
pnpm testTests are declarative YAML fixtures (test/*.test.yaml) driving the public
API against the in-memory filesystem: each scenario is a source filesystem
and either a target of expected outputs or the error the run must reject
with. Failures source-map back to the scenario's line in the YAML file. The
fixtures run through epureVitest; their shared Given is registered in
test/steps.ts.