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
.
BeforeAfterMultiple 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 WorldRaw (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 asPipeArg::Literal) - Bare numbers:
42(parsed asPipeArg::Literal) - Variable paths:
some_var(parsed asPipeArg::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 WorldPipe 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}}
.
helloThree-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}}
.
HELLOlowercase
Converts all characters to lowercase.
---
subject: Test
---
{{label | default "WORLD" | lowercase}}
.
worldcapitalize
Capitalizes the first character of the string. Remaining characters are left unchanged.
---
subject: Test
---
{{name | default "hello world" | capitalize}}
.
Hello worldtitlecase
Converts each word to title case (first letter uppercase, remaining lowercase), splitting on whitespace.
---
subject: Test
---
{{x | default "hello world" | titlecase}}
.
Hello Worldtruncate
| 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}}
.
paddedreplace
| 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 thereappend
| append "suffix" concatenates a string to the end of the value.
---
subject: Test
---
{{prefix | default "Order" | append " #12345"}}
.
Order #12345prepend
| prepend "prefix" concatenates a string to the beginning of the value.
---
subject: Test
---
{{x | default "world" | prepend "Hello "}}
.
Hello worldChaining 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 doeTernary 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: inactiveTernary 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"}}
.
yesTernary 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"}}
.
sameTernary 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"}}
.
biggerTernary 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 activeTernary 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: unknownPipes 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}}
.
NOControl 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):
||(logical OR)&&(logical AND)- Comparison operators (
==,!=,>=,<=,>,<) !prefix (negation)- 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
.
AfterTruthy boolean literal
The true keyword resolves to a truthy boolean. The block body is rendered.
---
subject: Test
---
:::if true
Always shown
:::
.
Always shownFalse boolean literal with else
false is always falsy. The else branch is rendered.
---
subject: Test
---
:::if false
Hidden
:::else
Visible
:::
.
VisibleNegation 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 trueString equality comparison
String literals in conditions use == for equality. Both double-quoted and
single-quoted strings are supported.
---
subject: Test
---
:::if "hello" == "hello"
Matched
:::
.
MatchedString inequality comparison
!= checks for inequality. Different strings evaluate to true.
---
subject: Test
---
:::if "a" != "b"
Not equal
:::
.
Not equalNumeric greater-than comparison
Numeric values in conditions support all comparison operators. Here, 5 > 3
is true.
---
subject: Test
---
:::if 5 > 3
Greater
:::
.
GreaterNumeric less-than comparison
---
subject: Test
---
:::if 3 < 5
Less than
:::
.
Less thanNumeric equality
---
subject: Test
---
:::if 5 == 5
Equal
:::
.
EqualGreater-than-or-equal
---
subject: Test
---
:::if 5 >= 5
GTE
:::
.
GTELess-than-or-equal
---
subject: Test
---
:::if 5 <= 5
LTE
:::
.
LTELogical 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 trueLogical OR
|| requires at least one side to be truthy. false || true is truthy.
---
subject: Test
---
:::if false || true
One true
:::
.
One trueNested 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 elseControl 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
.
AfterDirectives (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 contentHighlight 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
.
HighlightedNested 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 columnAction 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 meBlock Attributes
Block attributes use {key="value"} syntax. They can appear in two positions:
-
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. -
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:18pxBlock attribute sets text alignment
{align="center"} applies text-align:center to the paragraph.
---
subject: Test
---
Centered text
{align="center"}
.
text-align:centerBlock 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:700Multiple 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 contentNested 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 contentHard 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 OVERHard 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 lineSMS hard break
In SMS, hard breaks produce actual newline characters.
Line one
Line two
.
Line one
Line twoSlack hard break
In Slack mrkdwn, hard breaks produce newlines.
---
subject: Test
---
Line one
Line two
.
Line one\nLine twoPush hard break
In push notifications, hard breaks produce newlines in the body.
---
title: Test
---
Line one
Line two
.
Line one
Line twoExpressions with quotes inside link URLs
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