Architecture
Wit is not a monolith. It is four — optionally five — pure-ish functions in a line, each with a named, inspectable output type. Source text goes in one end; a string comes out the other. Once you see the shape you know where to intervene: swap the data loader, re-theme the renderer, write a renderer of your own, or catch an error at the right boundary.
The pipeline
parse turns source text into a Document AST. loadExternalData (optional) rewrites @load nodes
into data. resolve binds every use to the definition or data it
names and merges partials and cross-file references. expandevaluates — it inlines definition bodies, substitutes interpolations and body
slots, runs conditionals and iteration, and runs <% %> scripts. renderHtml and renderMarkdown walk the
fully-substituted tree to a string.
source ──parse──▶ Document ──loadExternalData──▶ Document ──resolve──▶ ResolvedDocument str (@witlang/parser) (optional, @witlang/runtime) (@witlang/runtime) │ expand (@witlang/runtime, runs <% %> scripts) ▼ str ◀──renderHtml / renderMarkdown── ExpandedDocument (@witlang/render-html / -markdown)
Three things to read off the diagram. The dashed loadExternalData step sits between parse and resolve. Scripts run at the tail of
expand — not as their own stage. Rendering is a pure tree walk: no I/O,
no configuration beyond mode and css.
Each stage owns one artifact: characters become tokens, tokens become a raw AST, the resolver binds every reference, the expander inlines definitions and evaluates logic, and a renderer turns the expanded AST into an output string.
The stages
| Stage | Input | Output | Package | May throw |
|---|---|---|---|---|
| parse | string | Document | parser | WitError |
| loadExternalData | Document + source | Document | runtime | E_LOAD_FAILED |
| resolve | Document | ResolvedDocument | runtime | ResolverError |
| expand | ResolvedDocument | ExpandedDocument | runtime | ExpanderError |
| render | ExpandedDocument | string | render-html/-markdown | none |
Order is parse → loadExternalData → resolve → expand → render,
exactly as the CLI does it. loadExternalData is optional: skip
it and a document with no @load behaves identically, so call it
always or never — your choice.
Which package owns what
- parser — lexer, parser, AST types, and errors.
- runtime — resolver, expander, data loader, and the script bridge.
- render-html and render-markdown — the two reference renderers.
- cli — the
witcommand that chains the stages. - vscode — the extension and language server, running the same stages in the editor.
Where tools stop early
Renderers consume any post-expand AST — they never re-parse. Tools that only need structure, such as the language server and a formatter, stop after parse or resolve; they don't need expansion. This is why the boundary between stages is a real interface, not an implementation detail.
The six-line pipeline
The whole machine, wired by hand:
import { parse } from '@witlang/parser'; // → Document
import { loadExternalData, resolve, expand } from '@witlang/runtime';
import { renderHtml } from '@witlang/render-html'; // → string
const doc = parse(source, 'file.wit'); // parse(source, file?='<inline>')
const loaded = loadExternalData(doc, dataSrc); // optional: rewrites @load → DataDef
const resolved = resolve(loaded, { rootPath }); // → ResolvedDocument
const expanded = expand(resolved); // → ExpandedDocument (runs scripts too)
const html = renderHtml(expanded, { mode: 'document' });For a document that needs no external data and no cross-file references, the whole thing composes into one line:
renderHtml(expand(resolve(parse('Hello from a *def*.')))) // → <article class="wit-doc"><p>Hello from a <strong>def</strong>.</p></article>
Watch the tree change shape
Each stage's output is a plain object you can inspect (wit tour prints
it as a readable tree). A pipeline inspector over one source makes the passes
concrete — log the block kinds, then the definitions and bindings the resolver
found, then the block kinds that survive expansion:
const src = '#greeting\nHello from a def.\ngreeting#\n\n@greeting'; const doc = parse(src, 'demo.wit'); const resolved = resolve(doc); const expanded = expand(resolved); doc.children.map(c => c.kind); // ['nodeDef', 'nodeUse'] [...resolved.definitions.keys()]; // ['greeting'] resolved.bindings.size; // 1 expanded.children.map(c => c.kind); // ['paragraph'] ← the def is gone
The definition disappears because expand inlined its body at the
use site. That is the whole story of the middle of the pipeline, in four lines.
Resolution timing
Not everything resolves at the same moment — this three-tier model is worth knowing before you build on the runtime.
- Eager. Pipe values, record right-hand sides, and conditional operands outside definition bodies are computed once by the resolver and bound.
- Expansion-time. A definition body is re-resolved on every use, so its interpolation and captured values reflect that call's arguments.
- Snapshot. An iteration freezes its collection at loop entry; the body then reads from that frozen sequence.
Design notes worth knowing
- Immutability. The resolver never mutates the parser AST.
Bindings live in a side table —
ResolvedDocument.bindings: Map<NodeUse, Binding>— not on the nodes. - Re-resolution.
expandre-resolves uses by name rather than reading that side table, because inlined bodies produce freshNodeUseclones the table never saw. - Scripts are part of expand. The
lhbridge is built and<% %>scripts run at the tail ofexpand, so renderers never encounter a script node. - Inspectability.
ResolvedDocumentcarriesMaps (definitions,dataDefs,bindings,partials), so log.keys()or.sizerather thanJSON.stringifyto see inside them.
Structural guarantees
Source locations — file, line, column, and length — ride every node through every stage, so an error or a highlight always points at an exact place in the source. Output is deterministic, and the AST is a serializable, exhaustive union, which is what lets tools and renderers attach at any stage boundary.
Common mistakes
- Handing a renderer a
ResolvedDocument. Definitions,(each), and(if)are still present; renderers expect anExpandedDocument. TypeScript catches it — the types are distinct on purpose. - Assuming a renderer re-parses. It consumes the expanded AST; it never touches source.
- Expecting
loadExternalDatato be required. It is a no-op for documents with no@load. - Expecting expansion at parse time. Parsing produces a raw tree with references and logic intact; expansion is a separate, later stage.
- Expecting scripts to run at render time. They ran during
expand; their effects are already baked into the tree.
See also
Parsing → the AST· AST reference · Resolve & expand · Rendering · Errors reference.