Expressions
Expressions are JSON objects placed as prop values inside elements. They are resolved at render time by the framework against the handler data. There are exactly two expression types: $data and $template.
Expressions appear only inside element.props. They are not resolved elsewhere in the spec.
$data — Type-Preserving Extraction
Format:
{ "$data": "/json/pointer/path" }
$data extracts the value at the given JSON Pointer path from the spec's data context. The resolved value replaces the entire expression object and preserves its original type.
Missing paths resolve to null.
Examples:
{ "$data": "/user/name" } // "Alice" (string preserved)
{ "$data": "/order/total" } // 99.50 (number preserved)
{ "$data": "/flags/active" } // true (boolean preserved)
{ "$data": "/items" } // [...] (array preserved)
{ "$data": "/missing" } // null (path not found)
In a complete element:
"total_card": {
"type": "StatCard",
"props": {
"label": "Total Revenue",
"value": { "$data": "/stats/revenue" }
}
}
With data { "stats": { "revenue": 12345 } }, the value prop resolves to 12345 (number).
$template — String Interpolation
Format:
{ "$template": "text {/path} more text" }
$template produces a string by substituting {/path} placeholders with values from the data context. Each placeholder uses JSON Pointer syntax. The result is always a string regardless of what the placeholders resolve to.
Missing placeholders substitute as "" (empty string). To emit a literal { or } character, escape it with a backslash: \{ and \}.
Examples:
{ "$template": "Hello, {/user/name}!" } // "Hello, Alice!"
{ "$template": "Order #{/order/id}" } // "Order #1042"
{ "$template": "Items: {/cart/count} in cart" } // "Items: 3 in cart"
In a complete element:
"greeting": {
"type": "Text",
"props": {
"content": { "$template": "Welcome, {/user/name}!" },
"element": "h1"
}
}
Where Expressions Apply
Expressions are resolved in element.props values only. They are not resolved in:
spec.title— accepts a literal string OR a{"$data": "/path"}binding (resolved at render time; see Spec Construction — title binding)spec.layout— literal stringspec.data— the data source itselfelement.children— always a list of element ID stringselement.action— handler name and method are literalelement.visible— visibility condition fields are literal
Expressions can appear at any depth inside a props object or array, but only as values — not as keys.
Using both expression types in one element:
"order_header": {
"type": "Text",
"props": {
"content": { "$template": "Welcome, {/user/name}!" },
"element": "h1"
}
}
Single-Pass Guarantee
If a $data expression resolves to a string value that looks like {"$data": "/another/path"}, the inner expression is not re-resolved. Expressions are evaluated in a single pass. This prevents injection and makes expression evaluation predictable.
Hard Cap — What Does Not Exist
The expression language is intentionally minimal. The following do not exist and will not be added:
$if— no conditional rendering in expressions$for— no loops in expressions$state— no client-side state$bind— no two-way binding$map— no array transformation in expressions$reduce— no aggregation in expressions
Conditional logic belongs in the Rust handler before calling render_file. Use the "visible" field on elements for simple show/hide logic based on data values. Complex branching is handled server-side — shape the data differently, or call render_file with a different spec path.
Infallible Semantics
Malformed expressions degrade to literal JSON values — the framework never panics on invalid expression objects. An expression with a non-string value or extra sibling keys is passed through unchanged. This is intentional for rendering reliability.
$each
$each instantiates one element per row in a data array.
The directive is element-level: it appears as a key on the element JSON object, alongside type, props, and children. It is resolved before render — by the time props are evaluated, the templated element has been replaced by N concrete clones.
"order_card": {
"type": "Card",
"$each": { "path": "/orders", "as": "order" },
"props": { "title": { "$data": "/order/order_number" } }
}
Fields
path— slash-separated JSON Pointer path to a JSON array inspec.data. Required.as— loop-variable name bound during expansion. Paths starting with/{as}/...in the templated element's props resolve to the current row. Required.
Expansion
For each row at index i in the resolved array, ferro:
- Clones the templated element.
- Rewrites prop expressions: paths starting with
/{as}/...resolve against the row data. - Assigns the clone the ID
{element_id}-{i}(e.g.,order_card-0,order_card-1, ...). - Removes the original templated element.
- Updates any parent's
childrenlist to reference the clone IDs.
The resolve order is: $if first, then $each, then resolve_actions, then resolve_expressions. By the time props are walked for $data / $template, the element graph is already flat and concrete.
Validation
The following errors are emitted at Spec::from_json time, before any data is bound:
EachPathNotArray— emitted as a best-effort check whenspec.datais non-null and the path resolves to a non-array value.EachAsReservedName—ascollides with one of the reserved names:data,root,_root,_each,this,self.NestedEach— a$each-templated element's transitive descendant (deeper than direct children) is also$each-templated.MismatchedEach— a$each-templated element's direct child is$eachover a different{path, as}pair than its parent.
Correlated children
Sibling templates with the same {path, as} pair are correlated by index: the i-th clone of one sibling references the i-th clone of the other. The pattern shows up when a Card and its Badge / DropdownMenu children all iterate over the same source array — each card's badge and dropdown belong to the same row.
"order_card": {
"type": "Card",
"$each": { "path": "/orders", "as": "order" },
"props": { "title": { "$data": "/order/order_number" } },
"children": ["order_badge"]
},
"order_badge": {
"type": "Badge",
"$each": { "path": "/orders", "as": "order" },
"props": { "label": { "$data": "/order/status_label" } }
}
With three orders, expansion produces order_card-0 through order_card-2, each referencing order_badge-0 through order_badge-2 correlated by index. Different {path, as} pairs at the same level are rejected as MismatchedEach.
Limitations
- Per-row
action.urlvalues are not synthesized from row data inside$each. The controller pre-resolves URLs intospec.dataand the templated element references them via{ "$data": "/order/advance_url" }. - Nested
$eachis rejected. If a use case appears, file an issue with the data shape.
Example: kanban cards from a data array
$each can template the card elements inside a fixed KanbanColumn. This is distinct from KanbanBoard.items_path + group_by, which buckets a flat item array into fixed lanes and renders each item with the prescribed title/description card.
| Pattern | Use when |
|---|---|
KanbanBoard.items_path + group_by | Fixed lanes; each item rendered with the prescribed card (title, description, dropdown) — no custom card structure needed |
$each inside KanbanColumn | Fixed lanes, but cards need custom structure (badges, nested elements) templated per item in a data array |
Spec with $each inside a fixed column:
{
"$schema": "ferro-json-ui/v2",
"title": "Orders",
"layout": "dashboard",
"root": "board",
"elements": {
"board": {
"type": "KanbanBoard",
"props": {
"columns": [
{ "id": "pending", "title": "Pending", "count": 3, "children": ["pending_card"] }
]
}
},
"pending_card": {
"type": "Card",
"$each": { "path": "/orders/pending", "as": "order" },
"props": {
"title": { "$template": "#{/order/number} — {/order/total}" },
"description": { "$data": "/order/customer_name" }
},
"children": ["pending_card_badge"]
},
"pending_card_badge": {
"type": "Badge",
"$each": { "path": "/orders/pending", "as": "order" },
"props": { "label": { "$data": "/order/status_label" } }
}
}
}
Handler data:
{
"orders": {
"pending": [
{ "number": "1042", "total": "€ 99,00", "customer_name": "Alice", "status_label": "New" },
{ "number": "1043", "total": "€ 45,00", "customer_name": "Bob", "status_label": "New" }
]
}
}
At resolve time, pending_card expands to pending_card-0 and pending_card-1; pending_card_badge expands to correlated clones. The KanbanBoard.columns[0].children list is rewritten to reference the clone IDs.
Use this pattern when the card needs custom structure (badges, nested elements). When the prescribed title/description card suffices, use KanbanBoard.items_path + group_by — it buckets a flat item array into the fixed lanes with no per-card templating.
$if
$if removes an element from the spec when its predicate evaluates false.
The directive is element-level: it appears as a key on the element JSON object. It is resolved before render; falsy elements are deleted from Spec.elements and no HTML is emitted for them.
"btn_advance": {
"type": "Button",
"$if": { "path": "/can_advance", "operator": "eq", "value": true },
"props": { "label": "Advance" }
}
Distinction from visible
$if and the visible prop both gate elements on a runtime condition, but they differ in when the check runs and in what reaches the response.
$if(resolve-time): falsy predicate → the element is removed from the spec. No HTML is emitted.visible(render-time): falsy condition → the element renders with hidden semantics. The HTML is present but suppressed.
Use $if when the element should not exist in the response at all — security-sensitive actions, structural omission, eliminating wasted bytes. Use visible when client-side toggling needs the DOM available.
Predicate syntax
$if reuses the same predicate shape as visible. Both flat conditions and compound forms are accepted:
- Flat condition:
{ "path": "/p", "operator": "eq", "value": x }. - Compound:
{ "and": [ ... ] },{ "or": [ ... ] },{ "not": { ... } }.
Operator names (snake_case): exists, not_exists, eq, not_eq, gt, lt, gte, lte, contains, not_empty, empty. The canonical name is eq, not equals — the equals form is not accepted.
Validation
IfPathMissing— emitted whenspec.datais non-null and the predicate path resolves toNone(the key is absent). This is distinct from a present-but-null value. The check fires atSpec::from_jsontime; runtime data is not validated this way.
Interaction with $each
When both $if and $each are present on the same element, $if is evaluated first. A falsy $if removes the element before $each would have expanded it; no clones are produced.
Missing-children behavior
When a parent's children list references an ID that was removed by $if, the render layer skips the reference. The page renders without the missing child; no error is raised. This is the same behavior the renderer already applies to references that point at nonexistent IDs.