Wit

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 reserved node), you're at the @node(type X) extension point — read the typeparameter and emit your widget.
  • Core vocabulary. When isCoreVocabName is true, take your built-in branch for h1, table, and the rest.
  • A user definition. Otherwise it's a definition that expand has 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 synchronous fileReader, not an async readFile — 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 parser

shares the front half of the pipeline; How to add a node or renderer covers the extension seams; Architecture maps the AST boundaries.