Norq

Expressions and Pipes

Expressions inject dynamic data into your templates. They use double-curly syntax and support pipes for transformations.

Variable interpolation

Use {{variable}} to insert a value. Dot-path notation accesses nested fields.

Hello {{user.first_name}},
 
Your order **#{{order.id}}** totals ${{order.total}}.

Variables are resolved from the data object passed at compile time (via --data, --sample, or the SDK’s data parameter).

Auto-escaping

All {{expressions}} are auto-escaped for the target channel:

  • Email: HTML entities (< becomes &lt;)
  • SMS: No escaping needed (plain text)
  • Slack: mrkdwn special characters escaped
  • Push: No escaping needed (plain text)
  • WhatsApp: WhatsApp formatting characters escaped
  • Teams: Adaptive Card text escaping

Raw output

Use triple-curly braces {{{expression}}} to output a value without escaping. This is useful when your data contains pre-formatted HTML or channel-native markup.

{{{order.custom_html}}}

The linter emits a warning (template/raw-expression) for every raw expression, since unescaped user input is a security risk.

Pipes

Pipes transform values inline. Chain them with |.

{{name | capitalize}}
{{name | uppercase | truncate 20}}

Available pipes

String pipes

Pipe Description Example
capitalize Capitalize first letter {{name | capitalize}}"hello""Hello"
uppercase Convert to uppercase {{name | uppercase}}"hello""HELLO"
lowercase Convert to lowercase {{name | lowercase}}"HELLO""hello"
titlecase Convert to Title Case {{name | titlecase}}"hello world""Hello World"
truncate N Truncate to N chars with ”…” {{desc | truncate 50}}
trim Strip whitespace from both ends {{name | trim}}" hi ""hi"
trim_start Strip leading whitespace {{name | trim_start}}
trim_end Strip trailing whitespace {{name | trim_end}}
replace OLD NEW Search and replace {{text | replace "old" "new"}}
append SUFFIX Append a string {{name | append "@example.com"}}
prepend PREFIX Prepend a string {{path | prepend "https://"}}
default VALUE Fallback if null {{nickname | default "Friend"}}
pluralize S P Singular/plural by count {{n | pluralize "item" "items"}}
md5 MD5 hash (useful for Gravatar) {{email | md5}}
split SEP Split string into array {{csv | split ","}}

Using | default on a nullable field suppresses the template/nullable-access linter warning. The fallback can be a quoted string or a variable path: {{nickname | default user.first_name}}.

Array pipes

Pipe Description Example
count Number of items {{items | count}}3
join SEP Join with separator {{tags | join ", "}}"a, b, c"
unique [KEY] Remove duplicates {{cities | unique}} or {{users | unique "email"}}
at INDEX Item at index {{emails | at 0}}
first First element {{items | first}}
last Last element {{items | last}}
reverse Reverse order {{items | reverse}}
sort [KEY] Sort ascending {{items | sort}} or {{users | sort "name"}}
slice START [LEN] Extract a slice {{items | slice 0 3}} — first 3 items

slice also works on strings: {{text | slice 0 5}} extracts the first 5 characters.

Math pipes

Pipe Description Example
add N Add {{price | add 5}}9 → 14
subtract N Subtract {{total | subtract 10}}50 → 40
multiply N Multiply {{qty | multiply 2}}5 → 10
divide N Divide {{cents | divide 100}}1500 → 15
round Round to nearest int {{score | round}}12.5 → 13
mod N Remainder {{index | mod 3}}14 → 2
abs Absolute value {{change | abs}}-10 → 10
ceil Round up {{price | ceil}}4.1 → 5
floor Round down {{price | floor}}4.9 → 4

Math pipes coerce numeric strings — "42" | add 8 produces 50. Division and modulo by zero return the original value.

Formatting pipes

Pipe Description Example
percent Format as percentage {{ratio | percent}}0.85 → "85%"
number [DECIMALS] Decimal places + thousand separators {{amount | number 2}}1234.5 → "1,234.50"
currency [CODE] Format as currency {{total | currency "USD"}}49.99 → "$49.99"

currency supports 30+ ISO 4217 codes: USD, EUR, GBP, JPY, INR, CNY, KRW, BRL, CAD, AUD, SGD, CHF, and more. Zero-decimal currencies (JPY, KRW) are handled automatically.

Serialization pipes

Pipe Description Example
json Serialize to JSON string {{user | json}}{"name":"Alice"}
from_json Parse JSON string to object {{json_str | from_json}}

Date pipes

Pipe Description Example
date [FMT] [TZ] Format date with optional timezone {{ts | date "%B %d, %Y"}}
timezone ZONE Convert to IANA timezone {{ts | timezone "America/New_York"}}
relative Relative time from now {{created_at | relative}} → “2 hours ago”

date accepts both Unicode-style tokens (MMMM d, yyyy) and strftime specifiers (%B %-d, %Y). Parses ISO 8601, RFC 3339, bare dates (2024-12-01), and Unix timestamps. Returns empty string for unparseable input. Default format: %Y-%m-%d.

Unicode tokens: yyyy (year), MM/M (month number), MMMM (month name), MMM (short month), dd/d (day), HH/H (24h hour), hh/h (12h hour), mm/m (minute), ss/s (second), a (AM/PM), EEEE (weekday), EEE (short weekday).

{{order_date | date "MMMM d, yyyy"}}
"2024-03-15" -> "March 15, 2024"

{{order_date | date "%B %d, %Y"}}
"2024-03-15T10:30:00Z" -> "March 15, 2024"

{{ts | date "%I:%M %p" "America/New_York"}}
"2024-03-15T10:30:00Z" -> "06:30 AM"

timezone converts a datetime to a different IANA timezone, returning RFC 3339 output. Chain with date for formatting: {{ts | timezone "Asia/Tokyo" | date "%H:%M"}}.

relative converts a datetime to a human-readable relative time. Returns “just now” for < 60 seconds, “N minutes ago”, “N hours ago”, “N days ago”, “N months ago”, or “N years ago”. Future dates return “in N minutes”, etc.

Variable pipe arguments

Pipe arguments can reference data variables. Bare unquoted words are resolved as variable paths; quoted strings and numbers are literals.

{{item.cost | multiply item.quantity | currency "USD"}}   Variable arg
{{name | append " Jr."}}                                  Literal arg (quoted)
{{price | multiply 2}}                                    Literal arg (number)

This lets you express cross-field calculations entirely in the template, without pre-computing values in your data payload. Variable arg resolution follows the same dot-path rules as regular expressions.

Pipe chaining

Pipes can be chained left to right. Each pipe receives the output of the previous one.

{{name | capitalize | truncate 20}}

Unknown pipes

At runtime, unknown pipe names pass through unchanged (forward-compatible). However, the linter and strict-mode validator flag them as errors (template/unknown-pipe, severity Error) with a Levenshtein-based suggestion if a close match exists — so {{ name | uppercse }} is caught before it ships. In strict mode (on by default), the send is aborted.

Pipe argument validation

Every pipe has a declared argument shape — arity bounds and per-position types. The linter (template/invalid-pipe-arg, severity Error) and the strict-mode validator enforce this, catching typos and wrong-type arguments that would otherwise silently coerce to 0 or "" at runtime:

Pipe Arity Arg types
capitalize, uppercase, lowercase, titlecase, trim, trim_start, trim_end, count, first, last, reverse, abs, ceil, floor, percent, json, from_json, relative, md5 0
truncate, at, add, subtract, multiply, divide, mod 1 Number
append, prepend, split, currency, timezone 1 String
round, number 0-1 Number (optional)
join, sort, unique 0-1 String (optional)
replace, pluralize 2 String, String
slice 1-2 Number, Number
date 0-2 format, optional timezone
default 1 any

Examples caught by the linter/validator:

  • {{ x | truncate "abc" }} — string literal where number expected
  • {{ x | truncate kdsjds }} — bare unknown word not in schema
  • {{ x | replace "only-one" }} — arity too low
  • {{ x | uppercase "extra" }} — arity too high

Ternary expressions

Use {{condition ? true_value : false_value}} for inline conditionals:

Status: {{order.shipped ? "Shipped" : "Processing"}}
Badge: {{user.tier == "pro" ? "Pro Member" : "Free Plan"}}

Ternary conditions support the same operators as :::if: truthiness checks, ==, !=, >, <, >=, <=, and logical operators &&, ||, !.

{{user.verified && user.tier == "pro" ? "Verified Pro" : "Standard"}}
{{!order.cancelled ? "Active" : "Cancelled"}}

Pipe precedence: Pipes apply to the entire ternary result, not individual branches:

{{is_vip ? "VIP" : "Regular" | uppercase}}

This produces "VIP" or "REGULAR" – the uppercase pipe transforms whichever branch is selected.

Style references ({.name})

Block attributes support a shorthand {.name} syntax to apply named styles from styles: in brand.yaml (or a frontmatter brand: override):

# Welcome aboard
{.headline}
 
Fine print below.
{.muted}

Multiple styles combine: {.headline .muted}. Later styles win on conflicts. Inline attrs override styles:

# Custom heading
{.headline size="4xl"}

The {.name} syntax is parsed as a special _style attribute. The linter emits template/unknown-style if the style name is not defined in config or frontmatter. See Project Configuration for defining styles.

Comments

Use {# ... #} to add comments that are stripped from all channel output. Works inline and multiline.

Hello {# greeting for logged-in users #} {{user.name}}
 
{# TODO: add tracking pixel after launch #}

Everything between {# and #} is removed before parsing — nothing reaches the compiled output.

Expressions in frontmatter

Frontmatter fields that support expressions use the same syntax:

---
subject: "Order #{{order.id}} has shipped!"
preheader: "Hey {{user.first_name}}, your order is on its way"
---

Expressions in native JSON mode

In .json template files, expressions use Handlebars (a JavaScript templating language) syntax. Pipes become Handlebars helpers:

{
  "text": "Hello {{capitalize user.first_name}}, you have {{count items}} items"
}

All pipes are available as Handlebars helpers with the same names.