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: ScriptBlockand 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_targetreaches a field keyed word target); collections as arrays. Unknown name → undefined. Writes are ignored.
  • lh.query(kindName) — every node whose AST kindequals 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 paramshas 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 witSourceand 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[&#39;word count&#39;] %> words.

<% function greet(name){ return &#39;hello, &#39; + 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>.

Realistic — query, then inject a count

@node |id fc|  |type finding| A holds node@
@node |id fc2| |type finding| B holds node@
@node |id count| node@

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

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

<%
lh.sort(&#39;nodeUse&#39;, (a, b) => weight(a) - weight(b));
lh.set(&#39;wordcount&#39;, lh.prose().wordCount());
lh.inject(&#39;stats&#39;, &#39;@p Total: &#39; + lh.data.wordcount + &#39; words. p@&#39;);
%>

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.
  • 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.