Wit

Data model

The values records, collections, parameters and @load share: six kinds, how to reach into them, how conditions test them, and how they map to JSON. Types: packages/parser/src/ast.ts.

Value kinds

DataValue
KindWritten asAST extras
stringValueany other textliteral — quoted or loaded; never read as markup
numberValue42 or 0.050 or -3raw — the number as written
booleanValueexactly true or false—
nullValueexactly null—
recorda brace map of key and value pairsfields — ordered key and value
collectiona bracket listitems — ordered values
source
#site: { name: Docs, founded: 2026, live: true, logo: null } !!
#tags: [ ref, spec, wit ] !!
#stats: { p: 0.050, isbn: 0262033844 } !!
#cite: { title: "a _raw_ title", note: an _emphasised_ note } !!

@site.name (@site.founded) · @tags.1 · p = @stats.p, ISBN @stats.isbn ·
@cite.title · @cite.note
result

Docs (2026) · spec · p = 0.050, ISBN 0262033844 · a _raw_ title · an emphasised note

Renders Docs (2026) · spec · p = 0.050, ISBN 0262033844 · a _raw_ title · an <em>emphasised</em> note.

  • Numbers match -?[0-9]+(\.[0-9]+)? and render as written.
  • A quoted value is always text and is used exactly as written; so is every string loaded through @load.
  • A record value runs until the next key:, so it may hold commas. Records and collections may span lines (commas then optional) and nest.
  • index, count and last are reserved field names (E_RESERVED_FIELD).

Access paths

@name.field.0 walks records by key and collections by index from 0. Keys match loosely: first name, first_name and firstName are one key (two such keys in one record is E_AMBIGUOUS_RECORD_KEY). A missing field renders a marker:

#keeper: { first name: Aldous, posts: [ Dunmore, Carrick ] } !!

@keeper.firstName kept @keeper.posts.1, not @keeper.nickname.
<p>Aldous kept Carrick, not <span class="wit-unresolved">@keeper.nickname</span>.</p>

A bare @name for a record or collection renders an empty <div class="wit-node"> placeholder — read a field, loop, or pass it to @table(rows: @name).

Loops

(each @list as item) … (end) binds each element. Read it as @item or @item.field. Each item also carries @item.index (from 0), @item.count and @item.last. Looping a non-collection is E_NOT_ITERABLE.

source
#crew: [ { name: Marlow }, { name: Singleton } ] !!

@ul
(each @crew as c)
@li @c.name (@c.index of @c.count) li@
(end)
ul@
result
  • Marlow (0 of 2)
  • Singleton (1 of 2)

Conditions

Condition forms
FormTrue when
if @paththe value is truthy
if @path is VALUEthe value equals VALUE — compared as written; a quoted VALUE compares its text
if ::param::inside a definition — the parameter's value is truthy

Truthy: defined, non-empty, not false, not null, not 0. A parameter whose value is a lone data reference (@m.met) is tested against that data, not the reference text.

source
#site: { name: Docs, live: false, visits: 0 } !!

(if @site.live)
Live.
(else)
Offline.
(end)

(if @site.name is Docs)
Named Docs; (if @site.visits)visited.(else)no visits yet.(end)
(end)
result

Offline.

Named Docs; no visits yet.

JSON mapping

What @load and toDataValue produce.

JSON to Wit
JSONWit
objectrecord
arraycollection
stringstringValue — literal
numbernumberValue
booleanbooleanValue
null or undefinednullValue

Numbers and booleans render as their text; in a table cell null is empty.

Parameters as data

A use's parameters are Param objects: { name, value, typedValue? }, with name: null for a positional value. typedValue is set when the value looks like a collection, record or scalar — that is how @table(rows: [[a, b], [1, 2]]) gets a table. A value that names a record or collection (rows: @sites) carries that data, so a definition can loop over a parameter.

On the NodeUse: paramsSource is 'parens' | 'none', and argsRaw keeps the text inside (…) so the expander can re-split it against the definition's declared parameters. A DataDef made by @load records the alias in loadedFrom.

See also

Syntax · Data guide · Config · API.