Control Flow
Norq supports two control flow constructs: :::if / :::else for conditionals and :::each for loops. These work in Markdown mode (.md files). For native JSON mode, see the native JSON page.
Conditionals (:::if / :::else)
Truthy check
The simplest form checks if a variable is truthy:
::: if order.shipped
Your order has shipped!
:::Truthiness rules: null, false, empty string "", and empty array [] are falsy. Everything else is truthy.
With else branch
::: if order.shipped
Your order has shipped!
:::else
Your order is being processed.
:::Comparison operators
Use comparison operators for more specific checks:
::: if status == "shipped"
Shipped!
:::
::: if order.total > 100
Free shipping applied!
:::
::: if items | count >= 5
Bulk discount available.
:::Supported operators: ==, !=, >, <, >=, <=
Both sides of a comparison can be:
- A variable path:
order.status - A quoted string:
"shipped" - A number:
100 - A boolean:
true/false
Nested conditionals
Conditionals can be nested:
::: if order.shipped
::: if order.tracking_id
Track your order: {{order.tracking_id}}
:::else
Tracking info will be available soon.
:::
:::Null safety
The linter checks whether variables used inside :::if blocks are properly guarded. If a field is nullable (not in the required list of data.schema.yaml), using it without a guard produces a warning:
warning: 'tracking_id' may be null — wrap in :::if guard or use | default "..."
--> notifications/order-shipped/email.md:14:5
|
14 | Tracking: {{order.tracking_id}}
| ^^^^^^^^^^^^^^^^^^^^^
= rule: template/nullable-access
Two ways to fix it:
Option 1: :::if guard — conditionally render the block:
::: if order.tracking_id
Tracking: {{order.tracking_id}}
:::Option 2: | default pipe — provide a fallback value:
Tracking: {{order.tracking_id | default "N/A"}}Loops (:::each)
Iterate over an array:
::: each order.items as item
- {{item.name}} x {{item.qty}} -- ${{item.price}}
:::The loop variable (here item) is scoped to the loop body. It does not leak into the surrounding scope.
Accessing loop items
Inside a loop, use the binding name to access each element:
::: each order.items as item
::: fields
Item: {{item.name}}
Quantity: {{item.qty}}
Price: ${{item.price}}
:::
:::Nested loops
Loops can be nested:
::: each departments as dept
**{{dept.name}}**
::: each dept.members as member
- {{member.name}} ({{member.role}})
:::
:::Combining loops and conditionals
::: each order.items as item
- {{item.name}} x {{item.qty}}
::: if item.on_sale
~~${{item.original_price}}~~ **${{item.price}}**
:::else
${{item.price}}
:::
:::Table directive (:::table)
:::table combines table rendering with array iteration. The header row renders once; the template row repeats for each item in the array.
::: table products as item
| Product | Qty | Subtotal |
|---------|-----|----------|
| {{item.name}} | {{item.qty}} | {{item.cost | multiply item.qty | currency "USD"}} |
:::The syntax mirrors :::each: the array path comes first, then as, then the loop binding name.
Channel compilation
| Channel | Output |
|---|---|
HTML <table> inside an <mj-section> |
|
| SMS / Push | Pipe-separated text rows (Product | Qty | Subtotal) |
| Teams | FactSet |
| Slack | Section block with pipe-separated fallback text |
| Pipe-separated plain text |
Pipe expressions inside table cells
Variable pipe arguments work naturally inside table cells:
::: table order.items as item
| Item | Price | Total |
|------|-------|-------|
| {{item.name}} | {{item.unit_price | currency "USD"}} | {{item.unit_price | multiply item.qty | currency "USD"}} |
:::The loop binding (item) is scoped to the table body and does not leak into the surrounding template.
Control flow in different channels
Control flow works identically across all channels. The conditional/loop logic is resolved against the data before the channel-specific compilation step. This means:
- The same
:::ifblock shows or hides content consistently across email, SMS, Slack, etc. - The same
:::eachloop expands identically in every channel - You write control flow once, and it works everywhere
Control flow in native JSON mode
Native JSON files (.json) do not support :::if / :::each syntax. Instead, use Handlebars control flow:
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "{{#if order.shipped}}Shipped!{{else}}Processing...{{/if}}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Items: {{#each order.items}}{{this.name}}{{#unless @last}}, {{/unless}}{{/each}}"
}
}
]
}