Expressions and pipes
This is the reference for the expression dialect inside {{ }}: how a source is classified, the operator grammar as the evaluator actually splits it, bracket indexing, scope frames, protocol reads, and every built-in pipe. Every example on this page was run through the real engine (lzr render-page, the same entry the site recipes use) and shows the output it actually produced. The single authority is the compatibility evaluator in internal/lzr/render/compat_expr.go — there is no separate spec.
How an expression is classified
Before evaluation, expr.ParseProgram (internal/lzr/expr/expr.go) sorts each {{ }} source into one of four kinds:
Expression kinds
Classification decides dependency extraction; evaluation of compat expressions happens in compat_expr.go.
| Kind | Shape | Behavior |
|---|---|---|
| Route capture | {{ {slug} }} — the whole source wrapped in single braces, identifier only | Resolves the route capture; dependency target route://capture/slug. Outside a routed render it fails with missing_value: missing value for route://capture/slug (verified). |
| Literal | 'text', "text", a number, true, false, null | Self-contained value, no dependency. |
| Path | Dotted identifiers: campaign.title. Identifier chars are A–Z a–z 0–9 _ - | Scope-chain lookup; dependency data://campaign#/title. Note hyphens are legal in a plain path but split as subtraction inside any compound expression. |
| Compat expression | Everything else: operators, pipes, indexing, protocol reads | Evaluated by compatEval.eval; dependencies are extracted by scanning roots and protocol addresses. |
Operator grammar and precedence
The evaluator has no token tree; it repeatedly splits the source at the top level (quote- and bracket-aware) in a fixed order. Whatever splits first binds loosest. The order, loosest to tightest:
Split order (loosest first)
From compatEval.eval in internal/lzr/render/compat_expr.go. All example outputs verified against the engine.
| # | Form | Semantics | Verified example → output |
|---|---|---|---|
| 1 | ( …) | Outer parens stripped; [] is the empty-list literal | (1 + 2) * 3 → 9 |
| 2 | helper:rest | Registered prefix helper (see extensions) | pack-dependent |
| 3 | c? a: b | Ternary; splits at the first top-level ? … : | 1 > 2? 'big': 'small' → small |
| 4 | a || b | Logical or; returns a boolean, never the operand | false || 0 → false |
| 5 | v | pipe:… | Pipe chain (see pipes) | 'abc' | sha256 | substr:0:8 → ba7816bf |
| 6 | scheme://… | Protocol read (see protocol reads) | data://campaign.json → payload value |
| 7 | a && b | Logical and; returns a boolean | true && 'yes' → true |
| 8 | a == b | Equality of printed forms | '1' == 1 → true |
| 9 | >= <=!= > < | Numeric when both sides are numbers; string when both are strings; != compares printed forms | 'b' > 'a' → true |
| 10 | a. b | PHP-style concat; the dot must have whitespace on both sides | 'x'. 'y' → xy |
| 11 | literals | 's'/"s", numbers, true false null. No escape sequences inside strings — use the other quote kind | 4.5 → 4.5 |
| 12 | a + b + … | N-ary. Sum when every part is a number, else string-concat of all parts | 1 + 2 + 3 → 6; 'a' + 1 → a1 |
| 13 | a - b, a * b, a / b | Numeric only, in that order; division by zero errors | 1 + 2 * 3 → 7; 10 / 4 → 2.5 |
| 14 | !a | Truthiness negation | !'' → true |
| 15 | path / index | Scope lookup with suffix chain (below) | items[0].title → Alpha |
Truthiness
One rule, from truthy in internal/lzr/render/shape.go, used by <if>, ternary, ||/&&, ! and the empty pipe: null, "" and numeric 0 are false; everything else is true — including empty lists and maps. Test emptiness of a collection with | empty or | jq:'length', not with bare truthiness.
Paths, bracket indexing, suffix chains
Paths resolve against the scope chain (next section). After any resolved base you can chain .field and [expr] segments freely. The bracket expression is a full expression itself. All verified against this corpus' data:
{{ campaign.title }} -> Corpus Campaign
{{ items[0].title }} -> Alpha (list index)
{{ items[-1].title }} -> Gamma (negative index counts from the end)
{{ items[1 + 1].title }} -> Gamma (computed index)
{{ labels.statuses['live'] }} -> Live now (quoted map key)
{{ labels['site']['meta']['lang'] }} -> en (chained brackets)
{{ labels.statuses[item.status] }} (loop-variable key)
Out-of-range list indices are hard errors (index 3 out of range); missing map keys are missing-value errors. Inside a foreach body, missing item subpaths render as an empty string instead (verified: {{ item.nope }} → "") — loop rows tolerate ragged data, page-level names do not.
Scope frames and name resolution
A name resolves through nested frames, innermost first. This is the same chain hypermake lzr scope-map reports (see Expressions and scope for the worked tour):
Resolution order
Innermost frame wins. Component-attribute precedence details live on the components page.
| Frame | Provides | Verified behavior |
|---|---|---|
| Loop frame | foreach/for item binding (key=, default item) and index binding (index=, default index, zero-based number) | {{ index + 1 }} → 1, 2, 3…; renamed via key="row" index=i. A foreach over a map iterates its values in key-sorted order. |
<set> bindings | <set key=name value=… />, in statement order | A later <set> with the same key shadows the earlier one from that point on (verified). A <set> inside an <if> branch or loop body is not visible after the branch closes — using it outside is the compile error template.binding_ambiguous (verified). |
| Component inputs | Attribute bindings of the enclosing component definition | See Components. |
| Page context | Route captures, initial scope (--scope-json), graph-provided context | Names do not auto-bind to data/*.json files — {{ items }} without a <set> fails with missing_value (verified). Bind protocol reads explicitly. |
Unresolvable names are build-failing diagnostics, not silent blanks: missing_value for plain paths, compat_expr_failed inside compound expressions. The sanctioned probes are the missing-tolerant pipes | isset and | empty — the only pipes that accept a missing input instead of propagating the error.
Protocol reads in expressions
An expression whose source starts with a known scheme is a protocol read. The compat evaluator recognizes src://, data://, cache://, public://, rendered://, projection://, client://, inspector://, project://, request-projection:// (hasProtocolPrefix, internal/lzr/render/core_helpers.go). Resolution asks the graph authority first; in page renders the file-backed schemes then resolve from the project root:
Read behavior, as verified
resolveProtocol / protocolPhysicalPath in compat_expr.go.
| Rule | Verified example |
|---|---|
.json reads decode to values; a $hyper entity envelope is unwrapped to its data payload automatically | {{ data://campaign.json }} yields the payload map — campaign.title on this page renders "Corpus Campaign" |
A bare name (no slash, no extension) appends .json; a bare data:// reads data/data.json | data://campaign ≡ data://campaign.json (verified) |
.html and .svg reads are trusted HTML (inserted unescaped in text position) | {{ src://sub/inc.html }} → <b>included</b> rendered as markup |
Everything else is a plain string; a missing cache:// file reads as "" instead of erroring | {{ cache://missing.txt }} → empty (verified) |
{expr} segments inside a path are interpolated before resolution | {{ data://sub/{which}.html }} with which = 'note' reads data/sub/note.html (verified) |
| A scheme the current authority cannot resolve falls back to the literal source string — silently | {{ inspector://timeline/recent.json }} in a plain page render outputs the address text itself (verified) |
Pipes
A pipe chain applies left to right: value | name:arg1:arg2. Arguments follow : and are full expressions themselves (quoted strings, numbers, paths — {{ labels | get:key }} works); :// inside an argument is protected from the split, so protocol addresses pass through intact. Pipe evaluation binds looser than &&/==/arithmetic and tighter than || and the ternary — {{ item.href | empty? 'unreleased': item.href }} is a verified working idiom from this corpus.
The complete built-in registry — every applyPipe case in compat_expr.go. Each output below is what the engine actually produced:
Built-in pipe reference (26 names)
Aliases: escape_html ≡ html, json_encode ≡ json, mb_substr ≡ substr. Args in [brackets] are optional.
| Pipe | Signature / behavior | Verified example → output |
|---|---|---|
html, escape_html | HTML-escape the printed value | '<b>x</b>' | html → <b>x</b> |
raw | Mark the printed value as trusted HTML. Text position is already unescaped (see output contract); attribute positions still escape even trusted values (verified) | '<i>x</i>' | raw in an attribute → <i>x</i> |
json, json_encode | JSON-encode the value | labels.site | json → {"meta":{"lang":"en"},"name":"Exprlab"} |
json_script | JSON-encode and mark trusted, for inline <script> payloads | var x = {{ labels.site | json_script }}; emits the JSON verbatim |
json_decode | Identity — protocol .json reads are already decoded; kept for compatibility | value unchanged (verified) |
jq | jq:'query' — full jq (gojq) over the value; compiled queries are LRU-cached (internal/lzr/datatransform) | items | jq:'map(.title) | join(", ")' → Alpha, Beta, Gamma |
get | get:'a.b' — dotted sub-path; missing paths yield the missing sentinel instead of an error (pair with empty/isset) | labels | get:'site.name' → Exprlab; … | get:'site.nope' | empty → true |
empty | Missing-tolerant: true for missing input, else !truthy | missingName | empty → true; 0 | empty → true; 'x' | empty → false |
isset | Missing-tolerant: false for missing input, else value!= null | missingName | isset → false |
array_merge | array_merge:m1[:m2…] — shallow map merge, later keys win; non-map inputs are ignored | labels.site | array_merge:labels.statuses | json → merged object (verified) |
sha256 | Canonical lowercase SHA-256 hex of the printed value | 'abc' | sha256 → ba7816bf8f01cfea…f20015ad (matches sha256sum) |
sha1 | Not SHA-1. Legacy-named 128-bit content fingerprint (hyperhash.Hex128String, xxhash-based, 32 hex chars) | 'abc' | sha1 → 06b05ab6733a618578af5f94892f3950 (real SHA-1 would be a9993e36…) |
shortHash | First 8 characters of the printed value — truncation, not hashing; use after a hash pipe | 'abc' | sha1 | shortHash → 06b05ab6 |
padl | padl:width[:pad] — left-pad the printed value (default pad " ") | '7' | padl:3:'0' → 007 |
subst | subst:from:to — replace all occurrences | 'a-b-c' | subst:'-':'_' → a_b_c |
substr, mb_substr | substr:start[:length] — rune-safe; negative start counts from the end, negative length trims from the end | 'hello world' | substr:-5 → world; … | substr:0:-6 → hello |
dirname | Directory part of a path (trailing slashes trimmed) | '/a/b/c.txt' | dirname → /a/b |
basename | basename[:suffix] — last path element, optional suffix strip | '/a/b/c.txt' | basename:'.txt' → c |
file_exists | Always returns false — a defused legacy compat shim; do not gate on it | '/a/b/c.txt' | file_exists → false (verified) |
prefixCssClasses | prefixCssClasses:'scope' — prefix each class-bearing selector block with .scope ; trusted output. Absent from the protocol-chain allowlist (callout above) | '.card { color: red}' | prefixCssClasses:'scope' → .scope.card{ color: red} |
svgToText | svgToText[:from[:length]] — extract <text>/<tspan> (or flowPara) content as trusted HTML; bold spans become <strong> | SVG with a bold title tspan → <strong>Title line</strong> Body copy here. (verified) |
charChunk | Wrap each character in <span>, spaces become <br>; trusted output (animation-friendly) | 'Hi' → <span>H</span><span>i</span> (verified) |
rich_text_html | block | rich_text_html:authority — validate a rich-text projection block (hypermake.rich-text.block.v1) against an independent media authority (…media-authority.v1) and emit its trusted HTML. The renderer additionally requires block and authority to come from two distinct graph data targets (rich_text_proof_incomplete otherwise) | data://rt/block.json | rich_text_html:data://rt/authority.json → <p>This is <strong>markdown</strong> …</p> (block generated by lzr project-rich-text, verified end to end) |
Unknown pipe names are hard errors: unsupported pipe "name". There is no machine-readable pipe registry today — this table is transcribed from applyPipe and verified case by case.
Extension registry: prefix helpers and pack pipes
Component packs can extend the dialect through rendercompat.Registry (internal/lzr/rendercompat/helpers.go): named pipes, prefix helpers (helperName:rest-of-expression, matched before everything else), and per-pipe missing-tolerance. The stock packs register: seoDetails (prefix + missing-tolerant pipe) and mkJsonLD from the SEO pack, mkImageDetails (prefix + pipe) and imageSources from the image pack (internal/lzr/componentpack/{seo,img}/helpers.go). They attach only when the pack's component directory is on the render's component path (attachBundledComponentPackHelpers, internal/lzr/client/component_registry.go) — this site does not load them, so they are cited here, not demonstrated.
Output and escaping contract
What a value becomes in the page
FormatRenderedValue + contentResultFromValue in internal/lzr/render/content.go; all rows verified, including under --minify.
| Rule | Verified example |
|---|---|
| Whole-number floats print as integers; other floats print full precision | 6 / 2 → 3; 0.1 + 0.2 → 0.30000000000000004 |
Booleans print true/false; null prints <nil> — map it away with a ternary before emitting | {{ null }} → <nil> (verified) |
Maps and lists print Go-style (map[key:value]) — emit structured data with | json | {{ labels }} → map[site:map[…]] |
Text position does not auto-escape. Attribute position always escapes, even | raw/trusted values | <p>{{ evil }}</p> with evil = '<b>x</b>' emits live markup; title="{{ evil }}" emits <b>… (verified) |
Verify this page
Every example above was run through the standalone renderer against a scratch project (probe templates p1…p17), and this page itself performs live reads of this corpus' data — the campaign title and jq count shown above are rendered, not typed. Reproduce any row with:
lzr render-page --root . --in src/probe.html --template probe.html --out out.html --sidecars skip hypermake lzr scope-map src/docs/expressions-pipes.html hypermake explain rendered/public/docs/expressions-pipes.html
Source authorities, in reading order: internal/lzr/render/compat_expr.go (eval, applyPipe, resolveProtocol), internal/lzr/render/content.go (output contract), internal/lzr/render/shape.go (truthy), internal/lzr/render/foreach.go (loop frames), internal/lzr/expr/expr.go (classification, dependencies), internal/lzr/richtext/validate.go (rich-text trust boundary).