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
Email 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
WhatsApp 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 :::if block shows or hides content consistently across email, SMS, Slack, etc.
  • The same :::each loop 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}}"
      }
    }
  ]
}