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
| Kind | Written as | AST extras |
|---|---|---|
| stringValue | any other text | literal — quoted or loaded; never read as markup |
| numberValue | 42 or 0.050 or -3 | raw — the number as written |
| booleanValue | exactly true or false | — |
| nullValue | exactly null | — |
| record | a brace map of key and value pairs | fields — ordered key and value |
| collection | a bracket list | items — ordered values |
#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
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,countandlastare 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.
#crew: [ { name: Marlow }, { name: Singleton } ] !! @ul (each @crew as c) @li @c.name (@c.index of @c.count) li@ (end) ul@
- Marlow (0 of 2)
- Singleton (1 of 2)
Conditions
| Form | True when |
|---|---|
| if @path | the value is truthy |
| if @path is VALUE | the 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.
#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)
Offline.
Named Docs; no visits yet.
JSON mapping
What @load and toDataValue produce.
| JSON | Wit |
|---|---|
| object | record |
| array | collection |
| string | stringValue — literal |
| number | numberValue |
| boolean | booleanValue |
| null or undefined | nullValue |
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.