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-levelfunction 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 aTextnode.@scriptCall(fn, …)invokes a captured function and splices its return the same way.
The lh bridge
lh.data.<name>— a read-only proxy over resolvedDataDefs. Scalars come back as JS primitives; records as a proxy with canonical-key access (lh.data.paper.word_targetreaches a field keyedword target); collections as arrays. Unknown name →undefined. Writes are ignored.lh.query(kindName)— every node whose AST kindequalskindName, as raw nodes. It queries by kind, not by node name — to find your@findingnodes, query'nodeUse'and filter onparams.lh.node(id)— the firstnodeUsewhoseparamshas a matchingid, orundefined.lh.sort(kindName, cmp)— reorder all instances of a kind in place within each parent (siblings preserved).cmpreceives raw nodes, so compare ona.params.find(p => p.name === 'weight')?.value.lh.inject(id, witSource)— parse and expandwitSourceand replace the body of the node whoseidparam matches. Best-effort in v1.lh.set(path, value)— write into an overlay map(no AST mutation); laterlh.datareads see it.lh.prose()→{ text, wordCount() }— concatenated text content with a word counter.
Basic — inline expr and a scriptCall
#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")
The paper has 1200 words.
The system says: hello, "world"
→ <p>The paper has 1200 words.</p><p>The system says: hello, "world"</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('nodeUse') .filter(n => n.params.some(p => p.name === 'type' && p.value === 'finding')); lh.inject('count', '@p ' + findings.length + ' findings. p@'); %>
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('nodeUse', (a, b) => weight(a) - weight(b)); lh.set('wordcount', lh.prose().wordCount()); lh.inject('stats', '@p Total: ' + lh.data.wordcount + ' words. p@'); %>
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_ERRORwith the block or callloc; the throw aborts expansion. - An unknown
@scriptCall(name)→E_SCRIPT_ERROR(references unknown function).
Common mistakes
- Expecting
lh.query('finding')to find@findingnodes — it queries the AST kind, so use'nodeUse'and filter. - Expecting
@scriptCallargs to be JS values — they are raw tokens; a quoted string keeps its quotes. - Expecting
lh.setto 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.