Norq

SMS Channel Conformance Spec

Conformance tests for the Norq SMS compiler. The SMS compiler takes Markdown input (with optional YAML frontmatter) and produces a plain-text body string.

Each example block specifies input markdown and one or more expected output fragments (substrings that must appear in SmsOutput.body).

Format:

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

The test runner compiles each input using serde_json::Value::Null as data and checks that every expected fragment appears as a substring of the body.


Plain Text Output

Basic paragraph

A plain paragraph renders as its text content followed by a double newline. Leading and trailing whitespace is trimmed from the final body.

Your order has been shipped.
.
Your order has been shipped.

Multiple paragraphs

Multiple paragraphs are emitted sequentially. Each paragraph appends \n\n after its text, so consecutive paragraphs are separated by a blank line.

First paragraph.
 
Second paragraph.
.
First paragraph.
.
Second paragraph.

Headings

Headings render as plain text — the Markdown level markers (#, ##, etc.) are discarded. The text content is emitted followed by \n\n, the same as a paragraph. No uppercase transformation is applied.

# Order Confirmed
.
Order Confirmed
## Shipment Update
 
Your package left the facility.
.
Shipment Update
.
Your package left the facility.

Inline Formatting

Bold stripped

Bold markers (**text** or __text__) are stripped. Only the inner text is emitted.

Your order **#12345** is ready.
.
Your order #12345 is ready.

Italic stripped

Italic markers (*text* or _text_) are stripped. Only the inner text is emitted.

Please _confirm_ your appointment.
.
Please confirm your appointment.

Inline code stripped

Inline code backtick markers are stripped; only the code text is kept.

Run `git pull` to update.
.
Run git pull to update.

Inline links render as their visible text. The URL is discarded (use :::action to expose a URL in the body).

Visit [our website](https://example.com) for more.
.
Visit our website for more.

Lists

Unordered list

Each unordered list item is prefixed with - and followed by a newline. There is no extra blank line between items.

- Item one
- Item two
- Item three
.
- Item one
.
- Item two
.
- Item three

Ordered list

Each ordered list item is prefixed with N. (1-based index) and followed by a newline.

1. First step
2. Second step
3. Third step
.
1. First step
.
2. Second step
.
3. Third step

Task list items

Checked task list items are prefixed with the ✅ emoji (U+2705) and unchecked items with the ☐ ballot box (U+2610), after the list bullet.

- [x] Account created
- [ ] Profile completed
.
✅ Account created
.
☐ Profile completed

Directives

:::action extracts the first link from its content and renders it as Label: url\n\n. The label is the link text; the URL is the href. If no link is present the directive emits nothing.

:::action
[Track package](https://example.com/track)
:::
.
Track package: https://example.com/track

:::highlight — star prefix

:::highlight content is prefixed with the ⭐ star emoji (U+2B50) and a space, then \n\n. Inline formatting inside the directive is stripped to plain text before the prefix is applied.

:::highlight
Special announcement
:::
.
⭐ Special announcement

:::footer children are recursively emitted after a leading newline separator. The footer appears at the end of the body.

Your order shipped.
 
:::footer
Reply STOP to unsubscribe.
:::
.
Your order shipped.
.
Reply STOP to unsubscribe.

:::fields — key-value lines

:::fields renders each paragraph child as a trimmed text line followed by a newline. An extra \n is appended after all field lines.

:::fields
Status: Shipped
Carrier: FedEx
:::
.
Status: Shipped
.
Carrier: FedEx

:::columns — stacked sequentially

:::columns has no visual meaning in SMS. Each :::col child's content is emitted sequentially as plain text.

:::columns
::: col
Left content
:::
::: col
Right content
:::
:::
.
Left content
.
Right content

:::header and :::hero — ignored

:::header and :::hero are silently ignored in SMS output. They produce no text.

:::header
App Name
:::
 
Your subscription is confirmed.
.
Your subscription is confirmed.
:::hero
![Banner](https://example.com/banner.jpg)
:::
 
Order confirmed.
.
Order confirmed.

:::raw — ignored

:::raw blocks are silently ignored in SMS; their content is never emitted.

:::raw
<html>some raw HTML</html>
:::
.

Control Flow

:::if — null data takes else branch

When data is null, condition paths resolve to null/falsy, so :::if blocks take the :::else branch (if present) or produce no output.

:::if user.is_premium
Premium content here.
:::else
Standard content here.
:::
.
Standard content here.

:::each — empty loop produces no output

When the iterable is null or missing, :::each produces no output.

:::each items as item
- {{item.name}}
:::
 
After the list.
.
After the list.

Expression Interpolation

Basic path expression

{{path}} expressions resolve the data path. With null data, an unresolvable path produces an empty string.

Hello there!
.
Hello there!

Pipes applied to expressions

Pipe functions transform expression values. The upper pipe converts the resolved string to uppercase.

Status: CONFIRMED
.
Status: CONFIRMED

Emoji Shortcodes

Emoji shortcodes (e.g., :wave:, :tada:) are expanded to their Unicode equivalents in the final body. Shortcode expansion runs after all other compilation, on the trimmed output.

Hello :wave: welcome!
 
:::highlight
Special announcement :tada:
:::
.
Hello 👋 welcome!
.
⭐ Special announcement 🎉

Blockquote

Blockquote children are rendered recursively as plain text. The > marker is not emitted.

> This is a quoted message.
.
This is a quoted message.

Images

Standalone images are silently ignored in SMS output.

![Badge](https://example.com/badge.png)
 
Order confirmed.
.
Order confirmed.

Thematic Breaks

Thematic breaks (***, ---, ___) are silently ignored in SMS output.

First section.
 
---
 
Second section.
.
First section.
.
Second section.

Section Directive

:::section content passes through as plain text.

::: section {bg="#f9f9f9"}
Hello from section
:::
.
Hello from section

Segment Calculation

The compiler calculates SmsOutput.segments and SmsOutput.encoding from the body text. These fields are informational only — they do not affect the body string.

GSM-7 encoding applies when every character in the body belongs to the GSM-7 basic charset or extended charset (^{}\\[~]|€). A single GSM-7 message holds 160 characters (or 153 per segment when multi-part). Extended characters count as 2 slots each.

UCS-2 encoding applies when any character falls outside GSM-7. A single UCS-2 message holds 70 characters (67 per segment when multi-part).

These segment rules follow the GSMA SMS standard and are not testable via the spec conformance runner (which checks body text only), but are validated by the unit tests in sms.rs.