Wit

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 its type param (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 emit use.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.raw or use.frozen is set, body is a single verbatim Text child. Emit it unescaped only for raw-text elements you trust (@@style, @@script); escape everything else.
  • Inline vs. block. use.inline tells you which; mirror the HTML renderer's BLOCK_KINDS set for the rest.
  • A surviving access path. use.access on a NodeUse that survived expansion is an unresolved data path — surface it visibly rather than dropping it (the HTML renderer emits a wit-unresolved span).
  • body can be null. Guard (use.body ?? []); a self-closing node has null, 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, or nodeDef in your walk. They are gone after expand; if you see one, expansion failed.
  • Re-parsing param.value when param.typedValue already holds the structured value.
  • Escaping a raw @@style or @@script body.
  • Forgetting body can be null.

See also

AST reference

· Rendering · Custom nodes · Resolve & expand.