Write a custom renderer
Walk the expanded Wit AST and emit your own format — LaTeX, plain text, a Slack payload, a JSON intermediate. The shipped HTML and Markdown renderers are working references.
Step 1 — Get an expanded document
Run parse, resolve and expand (add loadExternalData after parsing if the document loads data),
then walk expanded.children.
Step 2 — Recurse on node.kind
A plain-text renderer:
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 render(node) {
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 renderUse(node);
default: return '';
}
}
function renderUse(use) {
if (use.name === RESERVED_OPAQUE) return `[node type=${param(use, 'type')}]`;
const body = (use.body ?? []).map(render).join('').trim();
if (use.name === 'h1') return `# ${body}\n\n`;
if (use.name === 'li') return `- ${body}\n`;
if (use.name === 'ul') return body + '\n\n';
if (isCoreVocabName(use.name)) return body; // other built-ins: body only
return body;
}
console.log(expanded.children.map(render).join(''));For this source:
(h1 A Tiny Report h1) Some _emphasised_ and *strong* prose. @ul (li one li) (li two li) ul@
A Tiny Report
Some emphasised and strong prose.
- one
- two
it prints
# A Tiny Report Some _emphasised_ and **strong** prose. - one - two
# A Tiny Report
Some emphasised and *strong* prose.
- one - two
Step 3 — Dispatch a node use three ways
- The opaque node. When the name is
RESERVED_OPAQUE('node'), you're at the@node(type: x, …)extension point — read thetypeparameter and emit your widget. - Core vocabulary. When
isCoreVocabName(name)is true, take your built-in branch forh1,tableand the rest. - Anything else. User definitions are already inlined by
expand, so what remains is core vocabulary or an unknown name — render its body.
Step 4 — Read parameters
use.params is a list of { name, value } entries,
with value the text as written. When it looks like data, typedValue holds the parsed record, collection or scalar. Find
one by name, as the param helper does.
Step 5 — Know what expand removes
Definitions, data definitions, (if), (each),
interpolations and body slots never reach you, and <% %> scripts
have already run. Seeing one means you were handed an unexpanded tree.
Pitfalls
- Trim bodies. A body keeps the spaces next to its tags:
(em truly em)has the text" truly ". The shipped renderers trim at the edges; so should yours. - Bodies hold blocks. A container's body is a list of blocks
(paragraphs, nested uses), an inline use's body a list of inlines. Handle
paragraphinsiderender, as above. - Closeless uses.
use.bodyisnullfor a use with no body —@hr,@img(…). - Comments and raw nodes. Comments survive expansion; drop
them. A raw use (
use.raw) has one verbatim text child — emit it as is.
The AST surface
A block is a paragraph, comment, node use, definition, data definition,
reference, conditional, iteration or script block; after expansion you see
paragraphs, comments and node uses. An inline is text, italic, bold or a node
use. Print any file's tree with wit parse file.wit. Types: AST reference.
See also
Build your own renderer has the full contract; Embed the Wit parser shares the front half of the pipeline; Architecture maps the stages.