Wit

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:

source
(h1 A Tiny Report h1)

Some _emphasised_ and *strong* prose.

@ul
(li one li)
(li two li)
ul@
result

A Tiny Report

Some emphasised and strong prose.

  • one
  • two

it prints

source
# A Tiny Report

Some _emphasised_ and **strong** prose.

- one
- two
result

# 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 the type parameter and emit your widget.
  • Core vocabulary. When isCoreVocabName(name) is true, take your built-in branch for h1, table and 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 paragraph inside render, as above.
  • Closeless uses. use.body is null for 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.