Wit

Scripting & the lh bridge

<% %> is the escape hatch: plain JavaScript run against the expanded tree through a bridge object called lh. Reach for it when the declarative model can't express something — a derived value, a sort, injected generated content.

Where scripts run

Scripts execute inside expand(), after inline, if, and each expansion, in document order (last script wins on shared state). They are not a separate stage, and renderers never see them: ScriptBlock and ScriptCall are dropped from output because their effects already ran.

Two forms

  • Block <% … %> — run for side effects. Top-level function NAME(){} declarations are captured across blocks (the runner appends __env.NAME = NAME).
  • Inline <% expr %> — evaluated for its return value, which is spliced into prose as a Text node. @scriptCall(fn, …) invokes a captured function and splices its return the same way.

The lh bridge

  • lh.data.<name> — a read-only proxy over resolved DataDefs. Scalars come back as JS primitives; records as a proxy with canonical-key access (lh.data.paper.word_target reaches a field keyed word target); collections as arrays. Unknown name → undefined. Writes are ignored.
  • lh.query(kindName) — every node whose AST kind equals kindName, as raw nodes. It queries by kind, not by node name — to find your @finding nodes, query 'nodeUse' and filter on params.
  • lh.node(id) — the first nodeUse whose params has a matching id, or undefined.
  • lh.sort(kindName, cmp) — reorder all instances of a kind in place within each parent (siblings preserved). cmp receives raw nodes, so compare on a.params.find(p => p.name === 'weight')?.value.
  • lh.inject(id, witSource) — parse and expand witSource and replace the body of the node whose id param matches. Best-effort in v1.
  • lh.set(path, value) — write into an overlay map (no AST mutation); later lh.data reads see it.
  • lh.prose() → { text, wordCount() } — concatenated text content with a word counter.

Basic — inline expr and a scriptCall

source
#paper: { word count: 1200 } !!

The paper has <% lh.data.paper.word_count %> words.

<% function greet(name){ return 'hello, ' + name; } %>

The system says: @scriptCall(greet, "world")
result

The paper has 1200 words.

The system says: hello, "world"

→ <p>The paper has 1200 words.</p><p>The system says: hello, &quot;world&quot;</p>. A script on its own line is a block and inserts nothing; put an inline expression inside a line of prose.

Realistic — query, then inject a count

<%
const findings = lh.query('nodeUse')
  .filter(n => n.params.some(p => p.name === 'type' && p.value === 'finding'));
lh.inject('count', '(p ' + findings.length + ' findings. p)');
%>

@node(id: count) node@

@node(id: fc, type: finding) A holds. node@
@node(id: fc2, type: finding) B holds. node@

The @node(id: count) div's body becomes 2 findings. Note the query is 'nodeUse' (the AST kind) filtered by the type param — not 'finding'.

Edge — sort, overlay, and prose in one pass

<%
const weight = (n) => Number(n.params.find(p => p.name === 'weight')?.value ?? 0);
lh.sort('nodeUse', (a, b) => weight(a) - weight(b));
lh.set('wordcount', lh.prose().wordCount());
lh.inject('stats', '(p Total: ' + lh.data.wordcount + ' words. p)');
%>

@node(id: stats) node@

@node(weight: 2) The second finding. node@
@node(weight: 1) The first finding. node@

→ the two findings swap places (the stats node, weight 0, stays first) and the stats div reads Total: 6 words..

Execution model & security

Scripts are compiled with new Function('lh', '__env', …args, src) — there is no real sandbox. A script sees lh, the captured __env functions, and the host's global scope. Authoring input is trusted by design in v1.

Errors

  • A throwing script → E_SCRIPT_ERROR with the block or call loc; the throw aborts expansion.
  • An unknown @scriptCall(name) → E_SCRIPT_ERROR (references unknown function).

Common mistakes

  • Expecting lh.query('finding') to find @finding nodes — it queries the AST kind, so use 'nodeUse' and filter.
  • Expecting @scriptCall args to be JS values — they are raw tokens; a quoted string keeps its quotes.
  • Placing a script that injects or sorts after its targets — the change is lost. Put it first.
  • Expecting lh.set to mutate the AST — it writes an overlay only.
  • Expecting scripts to run at render time — they run in expand.
  • Embedding untrusted input — there is no sandbox.

See also

Resolve & expand (scripts are its tail) · AST reference (ScriptBlock, ScriptCall) · Rendering (script nodes dropped) · Errors reference.