Norq

Template Syntax Conformance Spec

Conformance tests for the Norq shared template syntax. This spec covers everything that applies across all channels: expressions, pipes, ternary expressions, control flow (conditionals and loops), directives, block attributes, and emoji shortcodes.

Because the syntax features are parsed before any channel compiler runs, these tests are channel-agnostic in principle. All conformance tests use channel=email (validating against the intermediate MJML output) since the email compiler's MJML is a stable, easy-to-fragment-match target.

The test runner compiles each input using the default EmailTheme with serde_json::Value::Null as data. Every expected fragment must appear as a substring of the compiled EmailOutput.mjml field.

Format:

  • Opening fence: 32 backticks followed by example channel=email
  • Input markdown (first section, before the first . separator)
  • . separator line
  • One or more expected fragments, each separated by . lines
  • Closing fence: 32 backticks

Expressions

Expressions are delimited by {{ }} (double curly braces). The parser (extract_expressions in expressions.rs) walks all text nodes and replaces {{ }} spans with Expression AST nodes. At compile time, the expression's path is resolved against the data object using dot-notation traversal. If the path does not exist in the data, Value::Null is returned and rendered as an empty string.

Static text passes through

Plain text with no expressions is passed through unchanged.

---
subject: Test
---
 
No expressions here.
.
No expressions here.

Missing variable resolves to empty string

An expression referencing a variable that does not exist in the data produces no visible output. The expression is consumed entirely — surrounding text is joined with no separator.

---
subject: Test
---
 
Before{{missing_var}}After
.
BeforeAfter

Multiple expressions on one line

Multiple {{ }} expressions on a single line are each independently resolved and their string representations are concatenated with the surrounding text.

---
subject: Test
---
 
{{x | default "Hello"}} {{y | default "World"}}
.
Hello World

Raw (unescaped) expression

Triple curly braces {{{ }}} produce raw (unescaped) output. In the email channel, normal {{ }} expressions are HTML-escaped, but {{{ }}} bypasses escaping. With Value::Null data the resolved value is an empty string, so the output paragraph is empty.

---
subject: Test
---
 
{{{raw_html}}}
.
<p></p>

Expression inside inline formatting

Expressions work inside bold, italic, and other inline formatting contexts. The parser recursively processes inline nodes, splitting text children that contain {{ }} patterns into interleaved Text and Expression nodes.

---
subject: Test
---
 
**{{x | default "Bold text"}}**
.
<strong>Bold text</strong>

Pipe Expressions

Pipes transform expression values. They are separated by | (pipe character) and applied left-to-right. The parser (parse_expression_inner) splits on | characters that are not inside quotes, then parses each segment into a pipe name and optional arguments.

Pipe arguments can be:

  • Quoted strings: "value" or 'value' (parsed as PipeArg::Literal)
  • Bare numbers: 42 (parsed as PipeArg::Literal)
  • Variable paths: some_var (parsed as PipeArg::Path, resolved at runtime)

Single pipe — default

| default "fallback" returns the fallback string when the input value is null. If the value is not null, it passes through unchanged.

---
subject: Test
---
 
Hello {{name | default "World"}}
.
Hello World

Pipe chaining

Pipes chain left-to-right. The output of each pipe becomes the input of the next. Here, default produces "HELLO", then lowercase transforms it to "hello".

---
subject: Test
---
 
{{name | default "HELLO" | lowercase}}
.
hello

Three-pipe chain

Multiple pipes can be chained in a single expression. Here, default provides the initial value, uppercase transforms it, and truncate 5 clips to 5 characters with an ellipsis.

---
subject: Test
---
 
{{x | default "hello world" | uppercase | truncate 5}}
.
HELLO...

uppercase

Converts all characters to uppercase.

---
subject: Test
---
 
{{label | default "hello" | uppercase}}
.
HELLO

lowercase

Converts all characters to lowercase.

---
subject: Test
---
 
{{label | default "WORLD" | lowercase}}
.
world

capitalize

Capitalizes the first character of the string. Remaining characters are left unchanged.

---
subject: Test
---
 
{{name | default "hello world" | capitalize}}
.
Hello world

titlecase

Converts each word to title case (first letter uppercase, remaining lowercase), splitting on whitespace.

---
subject: Test
---
 
{{x | default "hello world" | titlecase}}
.
Hello World

truncate

| truncate N clips the string to N characters and appends ... if truncated. If the string is shorter than or equal to N characters, it passes through unchanged.

---
subject: Test
---
 
{{x | default "This is a longer string" | truncate 10}}
.
This is a ...

trim

| trim strips whitespace from both ends of the string.

---
subject: Test
---
 
{{x | default "  padded  " | trim}}
.
padded

replace

| replace "search" "replacement" performs a global string replacement. All occurrences of the search string are replaced.

---
subject: Test
---
 
{{x | default "hello world" | replace "world" "there"}}
.
hello there

append

| append "suffix" concatenates a string to the end of the value.

---
subject: Test
---
 
{{prefix | default "Order" | append " #12345"}}
.
Order #12345

prepend

| prepend "prefix" concatenates a string to the beginning of the value.

---
subject: Test
---
 
{{x | default "world" | prepend "Hello "}}
.
Hello world

Chaining default, capitalize, and append

A realistic pipe chain: provide a default, capitalize the first letter, then append a suffix.

---
subject: Test
---
 
{{x | default "john" | capitalize | append " doe"}}
.
John doe

Ternary Expressions

Ternary expressions use the syntax {{condition ? true_value : false_value}}. The parser (parse_ternary in expressions.rs) splits on unquoted ? and : characters. The condition is parsed using the same condition parser as :::if directives. Branch values can be:

  • String literals: "text" or 'text'
  • Numbers: 42, 3.14
  • Variable paths: some.var (resolved against data)

Pipes after the false branch apply to the entire ternary result, not to individual branches. This is because split_pipes processes the rest-after- colon segment, separating the false value from trailing pipes.

Ternary with falsy condition

When the condition variable is missing (null, therefore falsy), the false branch is rendered.

---
subject: Test
---
 
Status: {{missing ? "active" : "inactive"}}
.
Status: inactive

Ternary with truthy boolean literal

The true keyword is parsed as ConditionValue::Bool(true), which is truthy. The true branch is selected.

---
subject: Test
---
 
{{true ? "yes" : "no"}}
.
yes

Ternary with string equality comparison

String literals in the condition are parsed by parse_value as ConditionValue::String. When both sides are equal, the true branch is selected.

---
subject: Test
---
 
{{"hello" == "hello" ? "same" : "diff"}}
.
same

Ternary with numeric comparison

Numeric values in the condition are parsed as ConditionValue::Number. Standard comparison operators (>, <, >=, <=, ==, !=) work on numbers.

---
subject: Test
---
 
{{5 > 3 ? "bigger" : "smaller"}}
.
bigger

Ternary with inequality on null

When a variable is null and compared with != to a string, the comparison evaluates to true (null is not equal to any string).

---
subject: Test
---
 
{{missing != "active" ? "not active" : "active"}}
.
not active

Ternary with path in false branch

When the condition is falsy (null variable) and the false branch references a variable path, that path is also resolved against data. Here, status is null, rendering as an empty string. The surrounding text is preserved.

---
subject: Test
---
 
Result: {{status ? status : "unknown"}}
.
Result: unknown

Pipes apply to entire ternary result

A pipe after the false branch transforms the final result. Here, the condition is falsy (null), so the false branch "no" is selected, then | uppercase transforms it to "NO".

---
subject: Test
---
 
{{missing ? "yes" : "no" | uppercase}}
.
NO

Control Flow — Conditionals

The :::if directive conditionally includes or excludes blocks of content. The condition is parsed by parse_condition in conditions.rs using a recursive-descent parser with the following precedence (lowest to highest):

  1. || (logical OR)
  2. && (logical AND)
  3. Comparison operators (==, !=, >=, <=, >, <)
  4. ! prefix (negation)
  5. Truthy (bare path or value)

Condition values can be: string literals ("text"), numbers (42), booleans (true, false), or variable paths (user.active).

Falsy condition with else branch

When the condition variable is null (falsy), the :::else branch is rendered.

---
subject: Test
---
 
:::if isPremium
Premium content here.
:::else
Standard content here.
:::
.
Standard content here.

Falsy condition without else

When the condition is falsy and no :::else is provided, the block body is omitted entirely.

---
subject: Test
---
 
Before
 
:::if showExtra
This should not appear.
:::
 
After
.
Before
.
After

Truthy boolean literal

The true keyword resolves to a truthy boolean. The block body is rendered.

---
subject: Test
---
 
:::if true
Always shown
:::
.
Always shown

False boolean literal with else

false is always falsy. The else branch is rendered.

---
subject: Test
---
 
:::if false
Hidden
:::else
Visible
:::
.
Visible

Negation operator

!variable negates the truthiness. When missing is null (falsy), !missing is truthy, and the block body is rendered.

---
subject: Test
---
 
:::if !missing
Negated true
:::
.
Negated true

String equality comparison

String literals in conditions use == for equality. Both double-quoted and single-quoted strings are supported.

---
subject: Test
---
 
:::if "hello" == "hello"
Matched
:::
.
Matched

String inequality comparison

!= checks for inequality. Different strings evaluate to true.

---
subject: Test
---
 
:::if "a" != "b"
Not equal
:::
.
Not equal

Numeric greater-than comparison

Numeric values in conditions support all comparison operators. Here, 5 > 3 is true.

---
subject: Test
---
 
:::if 5 > 3
Greater
:::
.
Greater

Numeric less-than comparison

---
subject: Test
---
 
:::if 3 < 5
Less than
:::
.
Less than

Numeric equality

---
subject: Test
---
 
:::if 5 == 5
Equal
:::
.
Equal

Greater-than-or-equal

---
subject: Test
---
 
:::if 5 >= 5
GTE
:::
.
GTE

Less-than-or-equal

---
subject: Test
---
 
:::if 5 <= 5
LTE
:::
.
LTE

Logical AND

&& requires both sides to be truthy. Since true && true is truthy, the block body is rendered.

---
subject: Test
---
 
:::if true && true
Both true
:::
.
Both true

Logical OR

|| requires at least one side to be truthy. false || true is truthy.

---
subject: Test
---
 
:::if false || true
One true
:::
.
One true

Nested conditionals

:::if directives can be nested. When the outer condition is falsy, the entire block (including inner conditionals) is skipped and the :::else branch is rendered.

---
subject: Test
---
 
:::if missing
:::if also_missing
Nested
:::
:::else
Outer else
:::
.
Outer else

Control Flow — Loops

The :::each directive iterates over an array. Its syntax is :::each collection as binding, where collection is a data path and binding is the loop variable name. If as binding is omitted, the binding defaults to item.

The parser (convert_each_directive in mod.rs) splits the params on whitespace and expects collection as binding.

Loop with null collection

When the collection path resolves to null (no data), the loop body is not rendered. Surrounding content is preserved.

---
subject: Test
---
 
Before
 
:::each items as item
{{item.name}}
:::
 
After
.
Before
.
After

Directives (General Syntax)

Directives are block-level constructs delimited by :::. The opening line is :::name or :::name {attrs}. The closing line is ::: on its own. The block scanner (scan_blocks in block_scanner.rs) detects these boundaries and nests the body blocks within a Directive node.

Directive attributes use {key="value"} syntax on the opening line. Bare params without braces (e.g., ::: footer center) are silently ignored — only the {...} form is recognized.

Callout directive with background color

::: callout {bg="..."} wraps content in a card-style section with the specified background color.

---
subject: Test
---
 
::: callout {bg="#eff6ff"}
Callout content
:::
.
background-color="#eff6ff"
.
Callout content

Highlight directive

::: highlight renders a visually distinct section. Without a bg attribute, the default highlight background from the theme is used.

---
subject: Test
---
 
::: highlight
Highlighted
:::
.
email-highlight
.
Highlighted

Nested directives — columns

Directives can nest. :::columns contains :::col children, each becoming a column in a multi-column layout.

---
subject: Test
---
 
::: columns
::: col
Left column
:::
::: col
Right column
:::
:::
.
Left column
.
Right column

Action directive

::: action wraps links as buttons. The enclosed link renders as an <mj-button> element.

---
subject: Test
---
 
::: action
[Click me](https://example.com)
:::
.
<mj-button
.
href="https://example.com"
.
Click me

Block Attributes

Block attributes use {key="value"} syntax. They can appear in two positions:

  1. Standalone paragraph: A paragraph whose sole text content is {...} (starting with { but not {{) is consumed as an attribute block and merged into the preceding node. The attribute paragraph is removed from the output.

  2. Trailing inline: A {...} string at the end of a paragraph (after a line break) is stripped from the paragraph's children and merged into that paragraph's own attrs.

The merge_block_attrs function handles both cases. It recognizes paragraphs, headings, lists, blockquotes, thematic breaks, and directives as valid merge targets.

Bare keywords (no =) are stored as key -> "" (empty string value). This enables flag-style attributes like {button}.

{{expression}} patterns are NOT consumed as block attrs because the parser checks for {{ (double-brace) and skips it.

Block attribute sets font size

{size="lg"} on the line after a paragraph sets font-size:18px on the compiled <p> tag.

---
subject: Test
---
 
Big text
{size="lg"}
.
font-size:18px

Block attribute sets text alignment

{align="center"} applies text-align:center to the paragraph.

---
subject: Test
---
 
Centered text
{align="center"}
.
text-align:center

Block attribute sets text color

{color="..."} applies an inline color style to the paragraph.

---
subject: Test
---
 
Red text
{color="#ff0000"}
.
<p style="color:#ff0000">Red text</p>

Block attribute sets font weight

{weight="bold"} sets font-weight:700 on the paragraph.

---
subject: Test
---
 
Important text
{weight="bold"}
.
font-weight:700

Multiple block attributes

Multiple attributes can appear in a single {...} block, separated by spaces. All are applied to the preceding element.

---
subject: Test
---
 
Styled text
{size="lg" align="center"}
.
<p style="font-size:18px;text-align:center">Styled text</p>

Block attributes on headings

Block attributes merge into heading elements. {align="center"} after a heading applies text-align:center to the <h2> tag.

---
subject: Test
---
 
## My Heading
{align="center"}
.
<h2 style="text-align:center">My Heading</h2>

Block attributes after inline formatting

When a paragraph contains inline formatting (bold, italic), block attributes on the following line still merge into the paragraph.

---
subject: Test
---
 
**Bold text**
{color="#ff0000"}
.
<p style="color:#ff0000"><strong>Bold text</strong></p>

Block attributes on blockquotes

Block attributes on a standalone {...} paragraph after a blockquote merge into the blockquote node.

---
subject: Test
---
 
> A quote
 
{align="center"}
.
<blockquote style="text-align:center"><p>A quote</p></blockquote>

Block attributes on thematic breaks (dividers)

Thematic breaks (***) produce <mj-divider> in email. Block attributes can set width and color on the divider.

---
subject: Test
---
 
***
{width="50%" color="#000"}
.
<mj-divider width="50%" border-color="#000" />

Expression is not consumed as block attribute

A paragraph containing {{expression}} (double braces) is NOT treated as a block attribute — it is a normal expression paragraph. The parse_inline_attrs function explicitly checks for {{ and returns None.

---
subject: Test
---
 
{{name}}
 
Next paragraph
.
<p></p><p>Next paragraph</p>

Emoji Shortcodes

Emoji shortcodes use the :name: syntax. The compiler (replace_emoji_shortcodes in emoji.rs) matches shortcodes against the gemoji v4.1.0 dataset (~1,870 emojis). Shortcode characters must be [a-z0-9_+-]. Unknown shortcodes are left unchanged in the output.

Emoji replacement happens at render time in the compiler, after expression resolution. This means emoji shortcodes in static text and in expression results are both expanded.

Single emoji shortcode

:rocket: expands to the Unicode rocket character.

---
subject: Test
---
 
Launching :rocket: now!
.
Launching 🚀 now!

Multiple emoji shortcodes

Multiple shortcodes on the same line are all expanded independently.

---
subject: Test
---
 
:white_check_mark: Done :tada:
.
✅ Done 🎉

Unknown shortcode passes through

A :name: pattern that does not match any known emoji is left as-is in the output. This prevents accidental replacement of text that happens to be wrapped in colons.

---
subject: Test
---
 
:not_a_real_emoji:
.
:not_a_real_emoji:

Wave emoji

:wave: expands to the waving hand emoji.

---
subject: Test
---
 
:wave: Hi there!
.
👋 Hi there!

Indentation Inside Directives

Content indented inside directives is stripped of common leading whitespace before Markdown parsing. This prevents indented content from becoming code blocks (4+ spaces triggers <pre><code> in standard Markdown).

Indented content is not a code block

Two-space indent inside a :::section directive is stripped — content renders as a normal paragraph, not <pre><code>.

---
subject: Test
---
 
::: section {bg="#f9f9f9"}
  Hello indented world
:::
.
<p>Hello indented world</p>

Four-space indent is stripped

Four spaces of indentation (which would normally trigger a Markdown code block) are removed before parsing, so the content renders as a paragraph.

---
subject: Test
---
 
::: callout
    Indented paragraph content
:::
.
Indented paragraph content

Nested directive indentation

Nested :::columns / :::col directives with indented body content each get their own indent-stripping pass. Content renders as paragraphs in their respective columns.

---
subject: Test
---
 
::: columns
  ::: col {width="1/2"}
    Left content
  :::
  ::: col {width="1/2"}
    Right content
  :::
:::
.
Left content
.
Right content

Hard Breaks

Standard Markdown hard breaks — two or more trailing spaces followed by a newline — produce a line break in the output. This is a fundamental Markdown feature that works across all channels.

In email, a hard break renders as <br /> inside the HTML content of an <mj-text> element. In plain-text channels (SMS, push), it produces an actual newline character. In JSON channels (Slack, WhatsApp, Teams), it produces a newline in the text content.

A hard break within a paragraph keeps the text in a single paragraph element with line breaks, unlike blank-line-separated text which creates separate paragraphs.

Email hard break

Two trailing spaces produce a <br /> within the paragraph.

---
subject: Test
---
 
THE  
WAIT  
IS OVER
.
THE<br />WAIT<br />IS OVER

Hard break with inline formatting

Hard breaks work alongside bold, italic, and other inline formatting.

---
subject: Test
---
 
**Bold line**  
*Italic line*  
Normal line
.
<strong>Bold line</strong><br /><em>Italic line</em><br />Normal line

SMS hard break

In SMS, hard breaks produce actual newline characters.

Line one  
Line two
.
Line one
Line two

Slack hard break

In Slack mrkdwn, hard breaks produce newlines.

---
subject: Test
---
 
Line one  
Line two
.
Line one\nLine two

Push hard break

In push notifications, hard breaks produce newlines in the body.

---
title: Test
---
 
Line one  
Line two
.
Line one
Line two

Expressions containing quoted strings (like default "https://...") inside Markdown link URLs must parse correctly. The markdown parser must not interpret quotes inside {{...}} as link title delimiters.

---
subject: Test
---
 
[View Docs]({{docs_url | default "https://docs.example.com"}})
.
>View Docs</a>
.
https://docs.example.com