Build your own renderer
The ship renderers are just tree walks. Anyone can write another — to LaTeX,
RTF, JSON, Slack blocks, plain text, anything. A renderer is a pure recursive
walk over ExpandedDocument.children; @witlang/render-html (~350 lines) and @witlang/render-markdown are the reference
implementations to mirror.
The kinds you actually handle
After expand the tree is narrow. You handle: paragraph, text, italic, bold, nodeUse, and a
stray record or collection (from a definition body that
collapsed to a literal). Everything else — nodeDef, dataDef, reference, interpolation, bodySlot, if, each — should be gone.Treat a survivor as an error or warning. comment, scriptBlock, and scriptCall may appear but are normally
dropped.
The minimal renderer
A render-to-plain-text you can run today. It is the whole pattern:
import type { ExpandedDocument } from '@witlang/runtime';
import { isReservedNodeName, RESERVED_OPAQUE } from '@witlang/runtime';
function render(node: any): string {
switch (node.kind) {
case 'paragraph': return node.children.map(render).join('') + '\n\n';
case 'text': return node.value;
case 'italic': return '*' + node.children.map(render).join('') + '*';
case 'bold': return '**' + node.children.map(render).join('') + '**';
case 'nodeUse': return renderNodeUse(node);
case 'record': return node.fields.map((f: any) =>
`${f.key}: ${renderValue(f.value)}`).join('\n');
case 'collection': return node.items.map(renderValue).join('\n');
default: return ''; // if/each/defs should be gone by now
}
}
function renderNodeUse(use: any): string {
const body = (use.body ?? []).map(render).join('');
if (use.name === RESERVED_OPAQUE) return dispatchByType(use, body); // @node
if (isReservedNodeName(use.name)) return body; // core vocab: your mapping
return body; // user def: body was inlined
}
function getParam(use: any, name: string): string | undefined {
return use.params.find((p: any) => p.name === name)?.value;
}Dispatching a NodeUse
The runtime exports two classifiers. isReservedNodeName is true for
the 52 core-vocab names plus node (53 in all); isCoreVocabName is true for the core names minusnode. Use them to branch three ways:
use.name === RESERVED_OPAQUE(the string'node') — the opaque container. Dispatch on itstypeparam (see Custom nodes).isReservedNodeName(use.name)— core vocab. Map it to your target's element in a lookup table of your own.- Otherwise — a bound user definition. Its body was already inlinedby
expand, so just emituse.body.
Reading params
getParam(use, 'x') reads a string value. A param that referenced data
carries param.typedValue — a DataValue — so read that
rather than re-parsing param.value:
const p = use.params.find((x: any) => x.name === 'rows'); const rows = p?.typedValue; // a Collection, ready to walk — don't re-parse p.value
Every edge to cover
- Raw and frozen bodies. When
use.raworuse.frozenis set,bodyis a single verbatimTextchild. Emit it unescaped only for raw-text elements you trust (@@style,@@script); escape everything else. - Inline vs. block.
use.inlinetells you which; mirror the HTML renderer'sBLOCK_KINDSset for the rest. - A surviving access path.
use.accesson aNodeUsethat survived expansion is an unresolved data path — surface it visibly rather than dropping it (the HTML renderer emits awit-unresolvedspan). - body can be null. Guard
(use.body ?? []); a self-closing node hasnull, not[]. - Testing. Mirror the ship renderers' test files — parse, resolve, and expand a fixture, then assert on your output string.
Common mistakes
- Handling
if,each, ornodeDefin your walk. They are gone afterexpand; if you see one, expansion failed. - Re-parsing
param.valuewhenparam.typedValuealready holds the structured value. - Escaping a raw
@@styleor@@scriptbody. - Forgetting
bodycan benull.