Write a custom renderer
Walk the expanded Wit AST and emit your own target format — LaTeX, plain text, RTF, a Slack payload, a JSON intermediate. The two shipped renderers (HTML and Markdown) are working references for exactly this. For developers.
Step 1 — Get an expanded document
Run parse, resolve, expand (add loadExternalData first if the document loads data), then walk the
children of the expanded document.
Step 2 — Recurse on node.kind
This walk produced correct plain-text output when verified:
import { parse } from '@witlang/parser';
import { resolve, expand, isCoreVocabName, RESERVED_OPAQUE } from '@witlang/runtime';
const expanded = expand(resolve(parse(src, 'doc.wit')));
const param = (use, n) => use.params.find(p => p.name === n)?.value;
function renderInline(node) {
switch (node.kind) {
case 'text': return node.value;
case 'italic': return '_' + node.children.map(renderInline).join('') + '_';
case 'bold': return '**' + node.children.map(renderInline).join('') + '**';
case 'nodeUse': return renderUse(node);
default: return '';
}
}
function renderUse(use) {
if (use.name === RESERVED_OPAQUE) return `[node type=${param(use, 'type')}]`;
const body = (use.body ?? []).map(renderInline).join('');
if (use.name === 'h1') return `\n# ${body}\n`;
if (use.name === 'li') return ` - ${body}`;
if (isCoreVocabName(use.name)) return body; // generic core-vocab branch
return body; // user def already inlined by expand
}Step 3 — Dispatch a node use three ways
- The opaque node. When the name equals
RESERVED_OPAQUE(the reservednode), you're at the@node(type X)extension point — read thetypeparameter and emit your widget. - Core vocabulary. When
isCoreVocabNameis true, take your built-in branch forh1,table, and the rest. - A user definition. Otherwise it's a definition that
expandhas already inlined — pass the body straight through.
Step 4 — Read parameters
Each node use carries a list of parameters; find one by name and read its value,
as the param helper above does.
Step 5 — Know what expand removes
A surviving definition, conditional, iteration, interpolation, or body slot in the tree you're walking signals a pre-expansion tree or an expansion failure — error on it or drop it, don't render it.
Result. The walk above turned an h1, a paragraph
with italic and bold runs, and a small list into # A Tiny Report, Some emphasised and *strong* prose.,
and two - bullets — verified as plain text.
The AST surface
A block is a paragraph, comment, node use, definition, data definition,
reference, conditional, iteration, or script block. An inline is text, italic,
bold, interpolation, body slot, node use, script call, or comment. A node use
carries its name, parameters, body, and flags. Inspect any file's tree first with wit parse path/to/file.wit, which prints the AST as JSON.
Pitfalls
- Sync
fileReader. As in embedding,resolvetakes a synchronousfileReader, not an asyncreadFile— older prose is stale here. - Handle scripts and comments. Decide explicitly what to do with a script block (evaluate it if you ship a script runtime, else drop it) — the shipped Markdown renderer drops comments and scripts.
Grounding
The two shipped renderers are the working reference — the HTML renderer and the
Markdown renderer, each with a green test suite. The Markdown renderer's dispatch
— hand-rolled mappings for h1 through table, transparent
sectioning wrappers, and unknown uses passed through unwrapped — is the model to
copy.
See also
Embed the Wit parsershares the front half of the pipeline; How to add a node or renderer covers the extension seams; Architecture maps the AST boundaries.