Skip to content

value and render

A step whose body is value binds the result of a pure expression. It is a named intermediate value.

- channel:
value: "{{ {'sale': '#wins', 'ticket': '#support'}[event.type] | default('#general') }}"
- lead:
value:
email: "{{ payload.email | trim | lower }}"
first_name: "{{ payload.name | split(' ') | first }}"

When value is a map or a list, implementations evaluate the expressions at its leaves and bind the structure as data. A value step is subject to purity: no I/O, and no nondeterminism.

A projection is a map of names to value-shaped structures, evaluated in a stated scope, that creates bindings in another scope. Projections obey every rule of this section. The projections in this specification are outputs, a trigger’s into, a transition’s into, and the with of render, agent, and call. Each supplies named values to a construct that has a closed scope.

match is not a projection. It supplies correlation values that select which event may reach a suspended run, and it creates no binding.

A step whose body is render evaluates a local template against supplied bindings, and binds the rendered text. A local template is prose or markup that the workflow owns. A render step is pure and subject to purity: no I/O at run time, and no nondeterminism.

render takes a string or a map. A string is a path to a template file, or to a directory of them. A map is an inline template, written in the Workfile itself. Both forms bind the same results and obey the same rules.

- reminder:
render: templates/cart_reminder_1
with:
items: "{{ cart.items }}"
discount: SAVE10
- alert:
render:
text: |
{{ ticket.id }} passed its deadline at {{ due | format_time('%H:%M') }}.
with:
ticket: "{{ ticket }}"
due: "{{ ticket.due_at }}"

Inline templates. An inline map has exactly one key. text, html, and json hold a template body as a string, and the key names the escape context, as a file extension does. parts holds a map of part name to single-key body map. The step then binds a map of part name to rendered string, as a directory does. Part names MUST match ^[a-z][a-z0-9_]*$ and MUST be unique.

- confirmation:
render:
parts:
subject: { text: "Order {{ order.id }} is confirmed" }
body_txt: { text: "Thanks, {{ order.customer_name }}." }
body_html: { html: "<p>Thanks, {{ order.customer_name }}!</p>" }
with: { order: "{{ order }}" }

A template has no ambient scope. Its variables are the names in with plus the block bindings below. A binding that is in scope at the step is invisible inside the body until with supplies it.

Files. A template needs no registration and no schema file. A path is a string literal, resolved from the project root by the same rule as call, and MUST NOT contain an expression. Template selection is static in both forms. A workflow that chooses among templates does so with route or choose over static render steps.

Parts. When the path names a directory, each non-partial file in it is a part. The step binds a map of part name to rendered string. The part name is the filename without its extension. When two files share a stem, the extension is appended after an underscore: subject.txt, body.txt, and body.html bind subject, body_txt, and body_html. Part names MUST match ^[a-z][a-z0-9_]*$ after this mapping, and MUST be unique. When the path names a single file, the step binds the rendered string directly. A file whose name begins with _ is a partial: it is not a part, it produces no binding, and it exists to be included.

The template body. Interpolations are {{ }} expressions in the grammar of operators, over a scope that holds exactly the names that with supplies plus any block bindings. The core filters and the namespaced filter packages apply unchanged. Two block forms exist, and no others:

{{#each items as item}}
<tr><td>{{ item.name }}</td><td>{{ item.qty }}</td></tr>
{{/each}}
{{#if discount != null}}
<p>Use code {{ discount }} at checkout.</p>
{{else}}
<p>Complete your order soon.</p>
{{/if}}

#each iterates a list with an explicit binding. There is no implicit context, and current resolves nowhere in a template. #if takes a boolean expression and an optional {{else}}. Blocks nest.

{{> _line_item.html}} includes a partial, which renders with the bindings that are in scope at the include site. A partial MUST NOT include another partial, and MUST NOT include itself. The inclusion depth is therefore at most one partial below a template.

Inferred inputs. A validator reads the variables that a template requires from its body. Implementations MUST check with against the template in both directions, before any run starts:

  • a variable that the template or its included partials reference, and that with does not supply, is an error;
  • a with key that the template never references is an error;
  • a variable is optional when every reference to it is guarded: wrapped in default(...), or reached only inside an {{#if}} whose condition establishes that it is non-null. An omitted optional variable binds null. A bare reference anywhere makes the variable required.

Escaping. The escape context comes from the file extension of a file template, and from the body key of an inline template. Escaping applies to every interpolation, and there is no opt-out. .html and html escape HTML. .json and json render an interpolation as JSON string content. text and every other extension escape nothing. A fragment that composes without escaping is a partial, rendered in the same context. A partial MUST have the extension that matches the context of every template that includes it.

Resolution and replay. Template content is part of the workflow’s definition, not run data. Implementations MUST resolve file content when a run starts, and MUST record it by content hash. An edit therefore never alters a run that is already in progress, and a resolved definition MUST embed the resolved content. An inline body needs no resolution, because the document holds it. A fault in an interpolation is an evaluation fault.

Templates that several projects share belong in template packages, published and namespaced like filter packages. Their format is deferred to a future version.