Resolve & expand
Everything between "I parsed a file" and "I can render it" happens in two runtime
passes. resolve is binding — it answers "what does @x mean?", tying every use to the definition or data it names and
merging partials and cross-file references. expand is evaluation — it produces the final tree: inlining bodies,
substituting interpolations and body slots, running conditionals and iteration,
and running scripts. A renderer author must know exactly what survives.
resolve
resolve(doc: Document, options?: ResolveOptions) → ResolvedDocument.
ResolveOptions has five fields, all optional — and notably no readFile:
rootPath?: string— the absolute path of the document. Required if the document has anyreferencedirective, or resolve throwsE_MISSING_REFERENCE_FILEup front.fileReader?: (absPath: string) => string— synchronous. How referenced files are read; defaults to a real filesystem reader. Supply your own for a virtual FS or in-memory documents.onMissingReference?: (path: string) => null | void— called instead of throwing when a referenced file is missing. Returnnullto skip it, or throw to restore strict behaviour. For in-progress documents.closeless?: ReadonlySet<string>— the graph's closeless names, fromscanCloseless, used when parsing each referenced file. Pass the same set you gaveparse.prepare?: (doc: Document) => Document— applied to each referenced file right after it is parsed. The CLI passes(d) => loadExternalData(d, loader)so a chapter's@loadlines load too.
scanCloseless(rootPath: string, rootSource: string, reader?: FileReader) → Set<string> walks the reference graph at token level (no full parse) and returns every name
defined without ::body::, plus br, hr, img. Unreadable or unparseable files are skipped; the real parse
reports them.
What resolve does
Three walks. First, it collects every NodeDef and DataDef into name-keyed maps — a duplicate non-additive name throws E_DUPLICATE_DEFINITION; additive +#x partials
accumulate. Second, it merges referenced files' definitions and data in. Third,
it walks every NodeUse and records a binding.
ResolvedDocument
children— the immutable parser AST (unchanged).definitions: Map<string, NodeDef>·dataDefs: Map<string, DataDef>.bindings: Map<NodeUse, Binding>— the side table tying uses to what they mean.partials: Map<string, NodeDef[]>— a working table: entries are folded intodefinitionsas aMergedNodeDef(withentriesandpartialSources) and the map is cleared, so it is empty on the returned document.references: Set<string>·resolvedFiles: Map<string, ResolvedDocument>— the cross-file graph, resolving every referenced file exactly once.unresolvedAt: Loc[]— reserved; always empty today.
Binding rules
- A
@@nameraw node records no binding — it is an opaque carrier and its body is verbatim. - An iteration item name in scope (
(each … as X)) resolves at expand time, so no binding is recorded. A definition's own parameters are pushed into scope the same way, so@person.nameand(each @people as p)inside#card(person, people)don't fail resolution. - Reserved core vocab and
@nodewithout an access path pass through with no binding. - A bare
@xresolves toNodeDef x(falling back toDataDef x); a dotted@x.yresolves toDataDef xfirst (falling back toNodeDef). - The first unresolved use throws
E_UNRESOLVED_REFERENCEat itsloc.
expand
expand(resolved: ResolvedDocument) → ExpandedDocument. It walks
the resolved tree and, in order: binds each use's parameters — re-splitting argsRaw against the definition's header (or, without
one, its captures) and naming positional values in header order;
inlines the definition body; substitutes Interpolation (::name::) and BodySlot (::body::);
evaluates each IfStatement to one branch; unrolls each EachStatement;
resolves ref-valued params onto param.typedValue; then builds the lh bridge and runs the scripts.
ExpandedDocument = { kind: 'expanded-document', children: Block[], loc }.
What is gone after expand
nodeDef, dataDef, reference, interpolation, bodySlot, and the evaluated if / each (their chosen blocks are spliced in). A
survivor of any of those signals an expansion failure — a renderer may treat it
as an error or warning. Recursive definitions throw E_EXPANSION_DEPTH_LIMIT at depth 256. Parameter binding throws E_POSITIONAL_WITHOUT_HEADER, E_TOO_MANY_ARGUMENTS, E_UNKNOWN_PARAM, E_MISSING_PARAM or, for a parameter missing its colon, E_REMOVED_SYNTAX.
Basic — a definition and a use
#greeting Hello from a def. greeting# @greeting
Hello from a def.
resolve reports definitions: ['greeting'] and bindings.size: 1; expand leaves children: ['paragraph'] (the definition inlined and gone);
render → <p>Hello from a def.</p>.
Realistic — additive partials
@ul (each @tags as t) (li @t li) (end) ul@ +#tags: alpha !! +#tags: beta !!
- alpha
- beta
The resolver collects every +#tags entry — from this file and
every referenced file, each once, in the order met — into one MergedNodeDef in definitions; its entries are what the loop walks → <ul><li>alpha</li><li>beta</li></ul>. Fixture 08-additive-partials.
Realistic — iteration
#team: [ { name: Ada, role: lead } { name: Béla, role: ic } ] !! @ul (each @team as p) (li @p.name — @p.role li) (end) ul@
- Ada — lead
- Béla — ic
→ <ul><li>Ada — lead</li><li>Béla — ic</li></ul>. Fixture 13-iteration.
Realistic — conditional
#book: { status: draft } !! (if @book.status is draft) Draft — do not distribute. (else) Final. (end)
Draft — do not distribute.
The draft branch renders; the else branch is dropped entirely. Fixture 12-conditionals.
Edge — cross-file references, in memory
Because fileReader is just a (absPath) => string, you
can resolve a document whose reference points at a file that exists
only in memory:
const files = {
'/proj/shared.wit': '#brand: { name: Northlight } !!\n\n#sig\n— the Northlight team\nsig#',
'/proj/main.wit': 'reference ./shared.wit\n\n@brand.name\n\n@sig\nand friends.',
};
const reader = (abs) => files[abs]; // synchronous lookup
const closeless = scanCloseless('/proj/main.wit', files['/proj/main.wit'], reader);
const doc = parse(files['/proj/main.wit'], '/proj/main.wit', { closeless });
const resolved = resolve(doc, { rootPath: '/proj/main.wit', fileReader: reader, closeless });
renderHtml(expand(resolved));
// → <article class="wit-doc"><p>Northlight</p><p>— the Northlight team and friends.</p></article>Without closeless, parse cannot know that sig takes no body and throws E_UNCLOSED_NODE at the @sig line.
Every option / edge
onMissingReferencereturningnullsilently skips the missing file; throwing from it restores strict resolution.rootPathabsent with areferencepresent →E_MISSING_REFERENCE_FILE.- A circular
reference→E_CIRCULAR_REFERENCE;resolvedFilescaches each file once, so a shared file parses only once. - A duplicate non-additive
#name→E_DUPLICATE_DEFINITION; a partial whose shape disagrees with its base →E_PARTIAL_SHAPE_MISMATCH. - A parameter value carrying a
typedValue(collection, record, or scalar) is pushed as an iteration frame, so with@card(person: @team.0)the body's@person.nameand(each @person.tags as t)resolve. E_NOT_ITERABLEwhen(each)targets a non-collection;E_AMBIGUOUS_RECORD_KEYwhen two keys of a record match the same loose key. A path to a missing field is not an error: it renders as a visiblewit-unresolvedspan. Fixtures11-data-accessand16-ambiguity.- An unknown
@name(no definition, no data, not core vocab) →E_UNRESOLVED_REFERENCEat resolve, before render. Contrast a surviving unresolved access path, which the renderer shows as a visible span (see Rendering).
Common mistakes
- Passing an
asyncfileReader— silently wrong. - Handing a
ResolvedDocumentto a renderer; renderers expect anExpandedDocument. - Expecting
dataDefsto still be in the expanded tree — they are consumed. - Expecting
bindingsto cover inlined clones —expandre-resolves by name, by design, so the side table does not list them.
See also
AST reference · External data (runs before resolve) · Rendering · Scripting (the tail of expand) · Errors reference.