Norq

Linting Spec

Defines the complete set of Norq lint rules, their severities, how the linter executes, and the structure of diagnostic output. The normative source of truth for rule behaviour is the linter implementation under crates/core/src/linter/; this document describes that behaviour in prose.


Overview

norq lint analyses notification templates without sending them. It parses each channel template, resolves partials, compiles the result where necessary, and emits a list of diagnostics. Diagnostics are grouped by file and channel.

The linter runs in three phases per channel template:

  1. Parse phase — The template text is parsed into an AST. Parse failures produce a parse/error (Error) diagnostic and abort further checks for that channel.

  2. AST phase — Rules that examine the parsed document structure: template rules, accessibility rules, channel-specific length and formatting rules, partial validation, and (for email) contrast checks.

  3. Payload phase — The AST is compiled with the first available sample (or an empty data object if no samples exist). Rules that can only be evaluated against the compiled payload run here: email HTML size, SMS segment count, Slack block count, push payload size, and Teams card size.

Structure rules run once per notification bundle, before any template is parsed. Theme-level contrast checks run once per project, against norq.config.yaml.

Execution

norq lint                              # lint all notifications in the project
norq lint transactional/order-shipped  # lint one notification by ID
norq lint --json                       # emit JSON output (suitable for CI)
norq lint --config /path/to/norq.config.yaml  # target a specific project

The linter resolves the project config using the same two-phase discovery as all other commands (walk up, then scan down). --config bypasses discovery and targets the specified file or its parent directory.

Linting runs in parallel across notifications using Rayon. Order of results is non-deterministic across notification boundaries, but diagnostics within a single channel result are in parse order.

Exit Codes

Exit code Meaning
0 No errors. Warnings and info may be present.
1 At least one Error-severity diagnostic was emitted.

Warnings and info diagnostics do not affect the exit code.


Severity Levels

Every diagnostic has exactly one severity.

Severity Symbol Meaning
error Compilation will fail or produce incorrect output. The notification cannot be delivered as authored. Must be fixed before deploying.
warning Output compiles but will likely cause problems in production: deliverability failures, accessibility violations, platform rejections, or silent drops. Should be fixed.
info Optimization opportunity, style concern, or advisory notice. Does not block delivery but is worth addressing.

Rule Categories

Rules are identified by a namespaced ID of the form category/rule-name. The categories are:

Category Description
structure/ Notification directory layout and file conventions
template/ Template syntax, attributes, variable references, and partial usage
partial/ Partial resolution and parameter passing
a11y/ Accessibility (WCAG 2.1/2.2)
email/ Email-specific deliverability, formatting, and spam
sms/ SMS encoding and segment limits
push/ Push notification title, body, and payload constraints
whatsapp/ WhatsApp Business API template limits
slack/ Slack Block Kit API limits
msteams/ Microsoft Teams Adaptive Card limits
parse/ Template parse failures (internal, not user-authored rules)

Structure Rules

Structure rules run before any template is parsed. They validate the notification directory layout against the conventions described in docs/spec/structure.md. Violations at this layer often prevent the resolver from finding the notification at all.

Rule Reference

Rule ID Severity Trigger
structure/missing-type-folder Warning A required type folder (system/, transactional/, promotional/) is absent
structure/invalid-root-entry Error An entry at the notifications/ root is not a type folder or _-prefixed directory
structure/invalid-name Error A directory name violates [a-z0-9]+(-[a-z0-9]+)*
structure/too-deep Warning A notification is nested beyond 4 levels from notifications/
structure/duplicate-channel-format Error Both .md and .json exist for the same channel in one notification
structure/no-channels Error A notification directory contains no channel files
structure/no-subdirectories Error A notification directory contains subdirectories
structure/all-channels-disabled Warning All channel files in a notification have enabled: false
structure/unrecognized-file Warning A file in a notification directory is not a recognised channel file or data file

structure/missing-type-folder

Severity: Warning

Norq requires exactly three top-level type folders under notifications/: system/, transactional/, and promotional/. This diagnostic fires once per missing folder. The resolver silently skips notification types whose folders do not exist; this warning makes that omission explicit.

Fix: create the missing folder(s), or run norq init on the project to scaffold missing structure without overwriting existing files.


structure/invalid-root-entry

Severity: Error

Only system/, transactional/, promotional/, and directories whose names begin with _ (e.g., _shared/) are permitted at the notifications/ root. Any other directory name, or any file at that level, is an invalid root entry. The resolver returns a hard error when it encounters one during traversal.

Fix: move the entry into a type folder, rename it to start with _ if it is a shared-partials directory, or delete it.


structure/invalid-name

Severity: Error

Notification, category, and subcategory directory names must match the pattern [a-z0-9]+(-[a-z0-9]+)*: lowercase letters and digits only, with hyphens as separators. No uppercase letters, underscores, spaces, or leading/trailing hyphens. Invalid names break ID derivation and routing.

Valid: welcome, order-shipped, password-reset, q4-2024.
Invalid: Order_Shipped, welcome!, my notification.

Fix: rename the directory using only lowercase letters, digits, and hyphens.


structure/too-deep

Severity: Warning

The resolver supports at most 4 levels from notifications/: type / category / subcategory / notification. Notifications nested deeper are silently ignored by the resolver and will never be discovered or compiled.

Fix: flatten the directory structure. Maximum valid path: notifications/transactional/account/security/password-reset/.


structure/duplicate-channel-format

Severity: Error

Both email.md and email.json (or the equivalent for another channel) exist in the same notification directory. The resolver cannot determine which format to use and returns a hard error. The same constraint applies to all channel pairs: sms.md/sms.json, slack.md/slack.json, etc.

Fix: delete one of the two files. Use .md for Markdown templates and .json only for channels that require native format (Slack Block Kit, Teams Adaptive Card).


structure/no-channels

Severity: Error

A directory that contains no channel files (email.md, sms.md, slack.md, push.md, whatsapp.md, msteams.md, or their .json variants) is not a valid notification. The resolver will not recognise it; it cannot be compiled or sent.

Fix: add at least one channel file, or delete the directory.


structure/no-subdirectories

Severity: Error

Notification directories must be leaf directories. Subdirectories inside a notification indicate a structural mistake — for example, accidentally nesting one notification inside another.

Fix: move any subdirectory out of the notification directory. Notification directories may contain only channel files, data.schema.yaml, data.samples.yaml, and tests.yaml.


structure/all-channels-disabled

Severity: Warning

When every channel file in a notification has enabled: false in its frontmatter (Markdown mode) or "$norq": { "enabled": false } in its JSON root (native mode), the notification cannot be delivered through any channel. This is almost always a mistake — leftover from disabling channels during development, or a misconfiguration.

Fix: set enabled: true on at least one channel, or remove the notification if it is no longer needed.


structure/unrecognized-file

Severity: Warning

A file inside a notification directory that Norq does not recognise is ignored during compilation. Recognised files are: the six channel files (.md or .json), data.schema.yaml, data.samples.yaml, and tests.yaml. An unrecognised filename often indicates a typo in a required filename (e.g., data-schema.yaml instead of data.schema.yaml).

Fix: check for typos and rename to a recognised filename, or move the file elsewhere.


Template Rules

Template rules apply to all channels. They catch problems in the template text itself: directive misuse, bad attributes, undefined variables, nullable-field access, and unsafe expressions. These rules run on the parsed AST and the raw source text.

Rule Reference

Rule ID Severity Trigger
template/unknown-directive Error A :::name directive is not in the recognised set
template/undefined-variable Warning A {{path}} expression references a variable not declared in data.schema.yaml
template/nullable-access Warning A nullable variable is used directly without an :::if guard
template/raw-expression Warning A triple-brace {{{expr}}} expression outputs unescaped content
template/thematic-break Warning A bare --- line in the body may be an unintended horizontal rule
template/directive-in-table Warning A directive appears inside a Markdown table cell
template/inline-directive Warning A ::: directive marker appears mid-line rather than at line start
template/unknown-attr Warning An unrecognised key appears in a {key="value"} attribute block
template/unsupported-attr Warning A recognised attribute is used on a directive or channel that does not support it
template/invalid-attr-value Warning An attribute value is not in the allowed set for that key
template/empty-directive-body Warning A directive has no content between its opening and closing markers
template/action-without-link Warning An :::action block contains no link
template/table-in-fields Warning A Markdown table is nested inside a :::fields directive

template/unknown-directive

Severity: Error

Recognised directive names are: header, footer, action, callout, highlight, centered, hero, fields, col, columns, media, list, raw, social, if, else, each, table. Any other :::name is unrecognised and is silently dropped during compilation — the content it wraps never appears in the output. This is guaranteed content loss.

Fix: correct the spelling. See the template directives reference for the full list.


template/undefined-variable

Severity: Warning

A {{path}} expression references a variable path not declared in data.schema.yaml. At runtime the expression renders as an empty string, which produces blank output where user data (name, order ID, etc.) was expected. This rule only fires when a data.schema.yaml is present; without a schema, variable references cannot be validated.

Fix: add the variable to data.schema.yaml, or fix the typo in the expression.


template/nullable-access

Severity: Warning

A variable is nullable (not in the required array of its parent schema object) and is referenced directly outside a guard. The :::if guard is the canonical way to test for existence before use. Without it, the variable may be absent in some payloads, rendering as an empty string or producing unexpected layout.

Fix: wrap the usage in an :::if guard:

:::if user.middleName
{{user.middleName}}
:::

template/raw-expression

Severity: Warning

A triple-brace expression ({{{value}}}) outputs its value without HTML escaping. If the value contains user-supplied content this is an XSS vulnerability. Even for non-user content, unescaped output can break the compiled email's HTML structure.

Fix: use double-brace {{value}} unless you are deliberately injecting trusted HTML. If you do need raw HTML output, confirm the value is safe and controlled.


template/thematic-break

Severity: Warning

A bare --- on its own line is ambiguous: it is both valid YAML frontmatter syntax and valid Markdown horizontal-rule syntax. In the template body it compiles to an <hr> element, which may not be the author's intent. It also triggers false positives in frontmatter parsers when placed near the top of the file.

Fix: use *** for intentional horizontal rules in the template body. Reserve --- for frontmatter delimiters only.


template/directive-in-table

Severity: Warning

Markdown table syntax treats | as a column separator. A :::directive inside a table cell breaks the table's parse structure, preventing the directive from rendering correctly.

Fix: move the directive outside the table. If you need structured data alongside a directive, place the directive before or after the table.


template/inline-directive

Severity: Warning

The ::: syntax is a block-level construct and must appear at the start of a line. A :::directive that appears mid-sentence is not recognised as a directive and will be emitted as literal text in the output.

Fix: move the directive to its own line with no preceding characters.


template/unknown-attr

Severity: Warning

The recognised block attribute keys are: align, bg, bg-image, bg-size, bg-repeat, bg-position, color, padding, size, weight, spacing, url, width, valign, variant, primary, secondary, danger, success, warning, button, button.secondary, button.success, button.danger, button.warning, full, thickness, border-radius. Any other key in a {key="value"} attribute block is silently ignored during compilation.

Fix: check the attribute name against the directive documentation. The rule message identifies the unrecognised key.


template/unsupported-attr

Severity: Warning

Some attributes are valid in general but not supported by a specific directive or channel. For example, layout attributes (size, weight, spacing, bg, color, align, padding) are email-only; using them in an SMS or push template has no effect. The attribute is accepted but silently ignored during compilation.

Fix: verify which attributes a directive supports on the target channel. Check the relevant channel documentation.


template/invalid-attr-value

Severity: Warning

Attributes that accept enumerated values (such as size and align) emit this diagnostic when an invalid value is provided. The invalid value is silently ignored during compilation, reverting to the default.

Valid size values: xs, sm, md, lg, xl, 2xl, 3xl, 4xl.
Valid align values: left, center, right.

Fix: use a value from the allowed set. The diagnostic message identifies the attribute and the rejected value.


template/empty-directive-body

Severity: Warning

A directive with no content between its opening :::name and closing ::: markers compiles to an empty block. This is almost always a mistake: content was accidentally deleted or the directive was left as a placeholder.

Fix: add content to the directive body, or remove the directive entirely.


Severity: Warning

An :::action block that contains no link produces a button with no destination URL. On email this is an unclickable button; on other channels it may be dropped entirely from the compiled output.

Fix: add a link inside the action block:

:::action
[Track Shipment]({{order.trackingUrl}})
:::

template/table-in-fields

Severity: Warning

The :::fields directive compiles to a key-value table layout. A Markdown table nested inside :::fields conflicts with that layout and is treated as raw text rather than a nested table.

Fix: move the table outside the :::fields block. Use :::fields for simple key-value pairs only.


Partial Rules

Partial rules validate {{> name}} include expressions and their parameter passing. They run against the raw source text, before expansion.

Rule Reference

Rule ID Severity Trigger
partial/not-found Error A {{> name}} reference cannot be resolved for the current channel
template/unknown-partial-param Warning A parameter passed to a partial is not defined in that partial
template/missing-partial-param Info A partial defines a parameter that was not passed in the invocation

partial/not-found

Severity: Error

A {{> name}} include that cannot be resolved for the current channel means the partial content is absent from the compiled output. The tag is left as-is in the output, producing broken or incomplete notifications. Resolution walks up the directory tree from the notification's location, checking each _shared/ directory at each scope level.

A partial must exist in a format compatible with the channel: .md works for all channels, .mjml for email only, .blocks.json for Slack only, .card.json for Teams only.

Fix: add the partial to a _shared/ directory in an appropriate scope. If the partial exists for a different channel (e.g., .mjml for email), add a .md fallback for other channels.


template/unknown-partial-param

Severity: Warning

A partial invocation passes a parameter name that is not referenced as {{@name}} inside the partial's content. This is almost always a typo in the parameter name or a stale parameter left over after refactoring the partial. The diagnostic includes a suggestion when a similar parameter name exists.

Fix: correct the parameter name, or remove the parameter if the partial no longer uses it.


template/missing-partial-param

Severity: Info

A partial uses {{@name}} internally but the invocation site did not pass a value for that parameter. The {{@name}} reference is left unexpanded in the output.

Fix: pass the required parameter at the invocation site: {{> partial-name param=some.value}}.


Accessibility Rules

Accessibility rules enforce WCAG 2.1/2.2 compliance and screen-reader best practices. They apply to all channels and run on the parsed AST.

Rule Reference

Rule ID Severity WCAG Trigger
a11y/alt-text-quality Warning 1.1.1 Image alt text is generic or filename-based
a11y/alt-text-length Info 1.1.1 Alt text exceeds 125 characters
a11y/image-only-link Warning 1.1.1 / 2.4.4 A link contains only an image with no text fallback
a11y/link-text-is-url Warning 2.4.4 Link text is a raw URL
a11y/table-missing-headers Warning 1.3.1 A data table has no header row
a11y/empty-heading Warning 1.3.1 A heading element has no text content
a11y/excessive-emoji Info More than 2 emoji in subject/preheader, or more than 3 in a heading or body paragraph
a11y/duplicate-heading-text Info 2.4.6 Same-level headings with identical text
a11y/adjacent-duplicate-links Info 2.4.4 Consecutive links pointing to the same URL

a11y/alt-text-quality

Severity: Warning
WCAG: 1.1.1 Non-text Content

Screen readers announce alt text to describe images. Generic values — the image's filename, image, logo, banner, photo — provide no useful information to visually impaired users. Spam filters also penalise missing or generic alt text.

Fix: replace generic alt text with a description that conveys the image's purpose:

![banner.png](https://cdn.example.com/banner.png)           <!-- bad: filename -->
![Summer sale: 30% off all accessories](https://...)        <!-- good: describes content -->

a11y/alt-text-length

Severity: Info
WCAG: 1.1.1 Non-text Content

Alt text over 125 characters becomes tedious for screen-reader users. Long descriptions are better placed in surrounding body text, where they are available to all readers rather than only those using assistive technology.

Fix: shorten alt text to under 125 characters. Move detailed descriptions into the body copy.


Severity: Warning
WCAG: 1.1.1 / 2.4.4

A link that contains only an image and no text fallback becomes invisible when the image fails to load — a common situation in email clients that block images by default. There is also no text for screen readers to announce as the link purpose.

Fix: add text alongside the image inside the link, or use a text link with the image as decoration. Alternatively, replace the image link with a button link:

[Shop Now](https://shop.example.com){button}

Severity: Warning
WCAG: 2.4.4 Link Purpose

Screen readers enumerate all links on a page. Raw URLs like https://example.com/orders/12345/confirmation are meaningless out of context and force the user to listen to the full URL being read aloud.

Fix: use descriptive link text:

[https://example.com/track](https://example.com/track)  <!-- bad -->
[Track your order](https://example.com/track)            <!-- good -->

a11y/table-missing-headers

Severity: Warning
WCAG: 1.3.1 Info and Relationships

Screen readers use table headers to announce column context as users navigate cells. Without a header row, data tables become a grid of unlabelled values with no way to understand what each column represents.

Fix: add a header row (a row using | separators followed by a separator row using ---):

| Product | Quantity | Price |
|---------|----------|-------|
| Widget  | 2        | $10   |

a11y/empty-heading

Severity: Warning
WCAG: 1.3.1 Info and Relationships

Screen-reader users navigate by headings to scan document structure. An empty heading creates a dead entry in the heading list and produces a gap in the document outline.

Fix: add text content to the heading, or remove it if it was added by mistake.


a11y/excessive-emoji

Severity: Info

Screen readers announce each emoji by its full Unicode name (e.g., "face with tears of joy", "rocket", "fire"). A subject line with five emoji forces the listener through five verbose announcements before reaching the message content.

This rule fires when:

  • A subject or preheader contains more than 2 emoji.
  • A single body paragraph or heading contains more than 3 emoji.

Fix: reduce emoji count. Use one emoji for emphasis rather than several for decoration.


a11y/duplicate-heading-text

Severity: Info
WCAG: 2.4.6 Headings and Labels

Screen-reader users who navigate by heading list see the same text appear twice with no way to distinguish which section is which. Unique headings help users jump directly to the section they want.

Fix: differentiate heading text — for example, "Shipping Details" and "Billing Details" rather than two headings both reading "Details".


Severity: Info
WCAG: 2.4.4 Link Purpose

Two consecutive links pointing to the same URL appear as separate entries in screen-reader link lists, creating confusion and requiring extra navigation steps to reach the same destination.

Fix: combine into a single link:

[View order](https://example.com/order) [details](https://example.com/order)  <!-- bad -->
[View order details](https://example.com/order)                                <!-- good -->

Email Rules

Email rules check deliverability, formatting, and spam-filter characteristics. They run only on email.md files (or email.json in native format). Rules that depend on the compiled HTML size run in the payload phase; all others run in the AST phase.

Rule Reference

Rule ID Severity Trigger
email/missing-subject Warning No subject key in frontmatter, or the value is blank
email/missing-preheader Warning No preheader key in frontmatter
email/missing-alt Warning An image has no alt text attribute
email/heading-hierarchy Warning Heading levels skip (e.g., h1 → h3)
email/link-text-quality Warning Link text is non-descriptive
email/low-contrast Warning A text/background colour pair fails WCAG 2.1 AA contrast
email/url-shortener Warning A link uses a known URL-shortening service
email/text-to-image-ratio Warning The compiled email is image-heavy with little text
email/size-limit Warning Compiled HTML exceeds 102 KB
email/subject-length Info Subject line exceeds 60 characters
email/preheader-length Info Preheader is outside the 40–130 character range
email/spam-words Info Subject or preheader contains known spam trigger phrases
email/subject-all-caps Info Subject line is entirely uppercase
email/subject-excessive-punctuation Info Subject contains two or more consecutive ! or ?
email/all-caps-body Info A body text block is entirely uppercase
email/missing-unsubscribe Info No unsubscribe link found (promotional notifications only)
email/font-size-minimum Info A size="xs" attribute produces 10 px text, below the accessibility minimum

email/missing-subject

Severity: Warning

Most email providers require a subject line. Without one, sends may be rejected outright or delivered with a blank subject, which severely reduces open rates.

Fix: add subject: to the frontmatter at the top of email.md:

---
subject: "Your order has shipped"
---

email/missing-preheader

Severity: Warning

When preheader is absent, email clients fill the inbox preview line with the first text from the email body — often a navigation link, disclaimer, or "View in browser" prompt. This wastes the premium preview space that could be used to drive opens.

Fix: add preheader: to the frontmatter:

---
subject: "Your order has shipped"
preheader: "Track your package and see delivery details"
---

email/missing-alt

Severity: Warning

Images without alt text fail accessibility standards and increase spam score. Screen readers skip the image entirely, and spam filters penalise image-only content. Note that this rule checks for the presence of alt text, not its quality; quality is checked by a11y/alt-text-quality.

Fix: add a descriptive alt attribute to every image:

![Order confirmation graphic](https://cdn.example.com/order.png)

email/heading-hierarchy

Severity: Warning

Screen readers use heading levels to navigate documents. Skipping a level (e.g., # h1 directly to ### h3 with no ## h2) breaks this structure, causing confusion for users with assistive technology.

Fix: use headings in sequential order. Start with # (h1), then ## (h2), then ### (h3). Do not skip levels.


Severity: Warning

Non-descriptive link text — "click here", "here", "this link", "read more", "learn more", "link" — is meaningless out of context. Screen readers enumerate links by their text; generic text makes navigation impossible. Spam filters also score these phrases negatively.

Fix: use text that explains the link's destination:

[Track your shipment](https://example.com/track/12345)  <!-- good -->
[Click here](https://example.com/track/12345)            <!-- bad -->

email/low-contrast

Severity: Warning

WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal-size text and 3:1 for large text (18 px+ or 14 px+ bold). Text/background colour pairs that fall below these thresholds are unreadable for users with low vision.

This rule checks colour pairs produced by the email theme — brandColor, buttonColor, bodyColor, headingColor, and related keys from norq.config.yaml. Per-directive colour overrides are checked where they can be statically resolved.

Fix: adjust theme colour keys in norq.config.yaml until all pairs pass. Use a contrast checker (e.g., the WebAIM Contrast Checker) to verify ratios.


email/url-shortener

Severity: Warning

URL-shortening services (bit.ly, tinyurl.com, t.co, goo.gl, ow.ly, is.gd, buff.ly, adf.ly, bl.ink, short.io) hide the destination domain from spam filters and recipients alike. Many corporate email gateways block messages containing shortened URLs outright.

Fix: use full, direct URLs. If link length is a concern in plain-text contexts, use descriptive link text to present a long URL rather than shortening it.


email/text-to-image-ratio

Severity: Warning

Emails composed almost entirely of images trigger spam filters because spammers use image-only emails to conceal content from text scanners. A low text-to-image ratio correlates strongly with promotion and spam classification.

Fix: add more text content alongside images. Describe what the image shows, add a caption, or include body copy.


email/size-limit

Severity: Warning

Gmail clips emails larger than 102 KB, displaying a "Message clipped" link in place of the remaining content. Hidden content often includes CTAs and unsubscribe links — both critical for the message to function.

This rule runs in the payload phase: the template is compiled with the first available sample and the resulting HTML is measured.

Fix: reduce inline content. Break long emails into shorter ones, move verbose copy to landing pages, and avoid inlining large base64-encoded images.


email/subject-length

Severity: Info

Mobile clients (Gmail on Android, Apple Mail on iPhone) typically truncate subjects around 40–60 characters. Recipients see a partial subject and may not open.

Fix: keep subjects under 60 characters. Front-load the most important words.


email/preheader-length

Severity: Info

A preheader under 40 characters leaves inbox preview space blank. One over 130 characters is truncated mid-sentence in most clients.

Fix: aim for 40–130 characters. Test across Gmail, Apple Mail, and Outlook.


email/spam-words

Severity: Info

Spam filters score messages based on known trigger phrases in the subject and preheader fields. The detected phrases are: free, winner, congratulations, act now, limited time, click here, buy now, order now, risk free, no obligation, 100% free, double your, earn money, cash bonus.

Fix: rephrase to remove trigger words. Focus on specific value rather than generic urgency.


email/subject-all-caps

Severity: Info

ALL-CAPS subjects are a classic spam signal and read as aggressive to recipients. Many filters penalise them.

Fix: use standard title case or sentence case.


email/subject-excessive-punctuation

Severity: Info

Subjects containing !! or ?? (two or more consecutive identical marks) are flagged by spam filters and read as shouting.

Fix: use a single punctuation mark, or none at all.


email/all-caps-body

Severity: Info

A body text block written entirely in uppercase reduces readability and is a secondary spam signal when combined with other indicators.

Fix: use normal casing. Use **bold** for emphasis rather than uppercase.


email/missing-unsubscribe

Severity: Info

This rule fires only on promotional notification type templates. CAN-SPAM (US) requires an unsubscribe mechanism in all commercial email. Gmail and Yahoo both enforce one-click unsubscribe for bulk senders as a deliverability requirement. A missing unsubscribe link risks regulatory exposure and inbox placement.

Fix: add an unsubscribe link, typically in a :::footer block:

:::footer
[Unsubscribe]({{unsubscribeUrl}}) · [Preferences]({{preferencesUrl}})
:::

email/font-size-minimum

Severity: Info

The xs size token maps to 10 px. The WCAG 1.4.4 accessible minimum and the broadly-accepted practical minimum for email body text is 14 px. Text at 10 px is unreadable for many users, particularly on mobile.

Fix: use sm (12 px) at minimum, or md (16 px) for body copy. Reserve xs only for legal-text contexts where you explicitly accept the tradeoff.


SMS Rules

SMS rules focus on encoding and cost. Character encoding and segment count have a direct impact on billing and delivery reliability. Rules run on the raw template source (AST phase) and on the compiled SMS payload (payload phase).

Rule Reference

Rule ID Severity Trigger
sms/no-images Warning Image syntax appears in an SMS template
sms/no-tables Warning Markdown table syntax appears in an SMS template
sms/encoding-warning Info Non-GSM-7 characters force UCS-2 encoding
sms/extended-gsm-chars Info Extended GSM-7 characters that each consume two character slots
sms/message-length Info The template text will likely exceed one SMS segment
sms/segment-count Warning (3+ segments) / Info (2 segments) The compiled message requires multiple segments

sms/no-images

Severity: Warning

SMS is a plain-text protocol. Image syntax (![alt](url)) in an SMS template will not render — recipients will see raw Markdown, a broken link, or nothing at all, depending on the provider.

Fix: remove image syntax from sms.md. If you need to share a visual, include a plain URL in the message text that links to the image.


sms/no-tables

Severity: Warning

Markdown table syntax (pipe-separated columns) does not translate to SMS. The pipe characters and separator dashes will appear as raw text, making the message unreadable.

Fix: remove tables. Present the same information as plain prose or a simple list.


sms/encoding-warning

Severity: Info

GSM-7 encoding allows 160 characters per segment. Characters outside the GSM-7 basic set — emoji, accented letters, smart quotes, curly apostrophes, or any other Unicode character not in the 128-character GSM-7 repertoire — force UCS-2 encoding. UCS-2 drops segment capacity to 70 characters. A message that fits in one GSM-7 segment may require three UCS-2 segments, tripling delivery cost.

Fix: avoid emoji and non-ASCII characters in SMS templates. Use straight quotes (', ") instead of typographic quotes. If non-ASCII characters are required, consciously accept the encoding cost and check segment count.


sms/extended-gsm-chars

Severity: Info

The following characters are technically in the GSM-7 character set but occupy two character slots in GSM-7 encoding: {, }, [, ], \, ~, |, ^, and . A message containing 10 of these characters consumes 10 extra character slots beyond what the visible count suggests.

Fix: be aware of double-counting when estimating message length. If you are near a segment boundary, replacing extended characters with alternatives can keep you within the previous segment.


sms/message-length

Severity: Info

A single SMS segment holds 160 GSM-7 characters or 70 UCS-2 characters. Multi-segment messages cost more and may arrive out of order on some carriers. This rule fires when a static analysis of the template text suggests the compiled message will exceed one segment.

Fix: shorten the message. Remove filler words and consider what information is truly essential in an SMS context.


sms/segment-count

Severity: Warning (3+ segments) / Info (2 segments)

This rule runs in the payload phase against the compiled SMS message. Each segment is billed separately. A 3-segment message costs three times a single-segment message. Carriers may also apply concatenation fees. Long messages also have higher delivery failure rates on some networks.

The severity is graduated: 2 segments triggers Info (acceptable for important messages), 3 or more segments triggers Warning (worth revisiting the content strategy).

Fix: shorten the message to fit within fewer segments.


Push Rules

Push notification rules exist because mobile platforms enforce hard payload and display limits. Violations result in silent delivery failures or truncated content with no error in application logs (unless the provider response is explicitly checked).

Rule Reference

Rule ID Severity Trigger
push/missing-title Warning No title source found in the template
push/title-length Warning The resolved title exceeds 50 characters
push/body-length Info The body text exceeds 120 characters
push/payload-size Warning The compiled payload exceeds 4 KB
push/too-many-actions Warning More than 2 action buttons

push/missing-title

Severity: Warning

Push notifications without a title display the app name (iOS) or no title at all (some Android configurations). A missing title reduces click-through rate and looks unprofessional. The linter considers any of these as a valid title source: a title: key in frontmatter, an :::header directive, or a # heading at the top of the template.

Fix: provide a title using any of these forms:

---
title: "Your order has shipped"
---

or an :::header directive, or a # Heading at the top of the body.


push/title-length

Severity: Warning

iOS truncates push notification titles at approximately 50 characters on lock screen and notification centre. Content beyond that is invisible to the recipient.

Fix: keep titles under 50 characters. Put the key information first.


push/body-length

Severity: Info

In collapsed notification view (the default), body text is typically truncated around 120 characters. Recipients who do not expand the notification miss everything after the cutoff.

Fix: front-load the most important information. Keep the first 120 characters self-contained so the message is useful even if not expanded.


push/payload-size

Severity: Warning

APNs (iOS) and FCM (Android) both enforce a 4 KB payload limit. Payloads over this limit are silently rejected — the notification never delivers, and no error appears in application logs unless the provider response is explicitly inspected.

This rule runs in the payload phase after compilation.

Fix: reduce content size. Store large data server-side and fetch it when the notification is tapped rather than embedding it in the payload.


push/too-many-actions

Severity: Warning

Cross-platform support for push action buttons is inconsistent. iOS supports up to 4 notification categories, but most practical UIs show 2. Android notification channels cap at 3. Beyond 2 actions, behaviour varies widely across devices and OS versions.

Fix: limit to 2 action buttons for reliable cross-platform behaviour.


WhatsApp Rules

WhatsApp Business API enforces strict limits on template structure during the Meta approval process. Violations result in template rejection before any message is sent, not at send time.

Rule Reference

Rule ID Severity Trigger
whatsapp/too-many-buttons Warning More than 3 buttons in an action block
whatsapp/body-length Warning Body text exceeds 1,024 characters
whatsapp/header-length Warning Header text exceeds 60 characters
whatsapp/footer-length Warning Footer text exceeds 60 characters
whatsapp/button-label-length Warning A button label exceeds 20 characters
whatsapp/cta-button-limit Warning More than 2 CTA (URL/phone) buttons

whatsapp/too-many-buttons

Severity: Warning

WhatsApp allows a maximum of 3 quick-reply or action buttons per template. Templates with more than 3 are rejected by the Meta API during submission.

Fix: reduce to 3 or fewer buttons. Prioritise the most important actions.


whatsapp/body-length

Severity: Warning

WhatsApp template body text is capped at 1,024 characters by the API. Templates that exceed this are rejected during Meta approval and cannot be sent.

Fix: shorten the body. If your message requires more context, link to a webpage.


whatsapp/header-length

Severity: Warning

WhatsApp header text is limited to 60 characters. Longer headers are rejected by the API.

Fix: keep header text concise — treat it as a subject line, not a paragraph.


whatsapp/footer-length

Severity: Warning

WhatsApp footer text is limited to 60 characters. Longer footers are rejected by the API. Common uses for footer text: brand name, opt-out notice.

Fix: keep footer text short.


whatsapp/button-label-length

Severity: Warning

Button labels in WhatsApp templates are capped at 20 characters. Labels that exceed this cause the template to be rejected or truncated.

Fix: use short, action-oriented labels: "Track Order", "View Details", "Get Offer".


whatsapp/cta-button-limit

Severity: Warning

WhatsApp allows a maximum of 2 call-to-action buttons (URL or phone type) per template. Exceeding this limit causes template rejection.

Fix: limit to 2 CTA buttons. Use quick-reply buttons for additional options if needed.


Slack Rules

Slack's Block Kit API enforces limits on blocks and element counts. Payloads that exceed them receive API errors and fail to post, silently from the author's perspective.

Rule Reference

Rule ID Severity Trigger
slack/block-count Warning More than 50 blocks in the payload
slack/section-text-length Warning A section block's text exceeds 3,000 characters
slack/actions-count Warning An actions block contains more than 5 elements

slack/block-count

Severity: Warning

The Slack API rejects messages with more than 50 blocks. If the block count exceeds this limit, the entire message fails to post — the API returns an error and the notification is silently lost.

This rule runs in the payload phase against the compiled Block Kit JSON.

Fix: reduce content. Consolidate related sections, use fewer blocks, or split the notification into multiple messages.


slack/section-text-length

Severity: Warning

Slack limits section block text to 3,000 characters. Sections that exceed this are rejected by the API.

Fix: split long sections into multiple section blocks, or move detailed content to a linked webpage.


slack/actions-count

Severity: Warning

An actions block in Slack supports a maximum of 5 interactive elements (buttons, selects, etc.). More than 5 causes the API to reject the entire message payload.

Fix: limit action blocks to 5 elements. Combine or remove less important actions.


MS Teams Rules

Teams Incoming Webhook cards have payload size and action limits that result in webhook rejection when exceeded.

Rule Reference

Rule ID Severity Trigger
msteams/card-payload-size Warning Card payload JSON exceeds 28 KB
msteams/action-count Warning More than 6 top-level actions

msteams/card-payload-size

Severity: Warning

Microsoft Teams Incoming Webhooks reject card payloads larger than 28 KB. The webhook returns an error and the notification does not appear in the channel.

This rule runs in the payload phase against the compiled Adaptive Card JSON.

Fix: reduce card content. Remove unnecessary images, shorten text, and avoid embedding large data in the card body. Link out to detailed content instead.


msteams/action-count

Severity: Warning

Teams Adaptive Cards support up to 6 top-level actions. Actions beyond the limit are pushed into an overflow menu that most users will not discover.

Fix: limit the card to 6 actions. Surface only the most important actions directly; remove or consolidate the rest.


Linter Configuration

The linter has no configuration today. All rules are always active; there is no mechanism to suppress individual rules or override severities in norq.config.yaml or in frontmatter.

Future versions will add per-project and per-template rule suppression.


JSON Output

norq lint --json emits a protocol envelope with a results key. The value is an array of result objects, one per channel file with diagnostics.

Envelope

{
  "ok": true,
  "data": {
    "results": [ ... ]
  }
}

ok is true whenever the command exits successfully (including when warnings are present). It is false only when the command itself fails with an unrecoverable error (e.g., config not found). Exit code 1 is used when at least one Error-severity diagnostic is present — this is independent of the ok field.

Result Object

Each element of results corresponds to a single channel file:

{
  "file": "notifications/transactional/welcome/email.md",
  "channel": "email",
  "diagnostics": [ ... ]
}
Field Type Description
file string Relative path to the channel file that produced the diagnostics
channel string Channel name: email, sms, slack, push, whatsapp, msteams
diagnostics array Zero or more diagnostic objects

Result objects with zero diagnostics are omitted from the output.

Diagnostic Object

{
  "rule": "email/missing-subject",
  "severity": "warning",
  "message": "Email template has no `subject` in frontmatter.",
  "position": {
    "start_line": 1,
    "start_col": 1,
    "end_line": 1,
    "end_col": 3
  }
}
Field Type Description
rule string Rule identifier (e.g., email/missing-subject)
severity string "error", "warning", or "info"
message string Human-readable description of the issue
position object or absent Source location; omitted when the diagnostic is not tied to a specific line

Position Object

{
  "start_line": 4,
  "start_col": 1,
  "end_line": 4,
  "end_col": 25
}

Line and column numbers are 1-based. end_line equals start_line for single-line diagnostics. Position is absent for diagnostics that apply to the whole file (e.g., email/missing-subject when there is no frontmatter) and for structure-level diagnostics that apply to a directory rather than a file.

When a diagnostic originates inside a partial that is expanded into the template, the reported position is remapped to the {{> partial-name}} invocation line in the parent template rather than the line inside the partial file.


Complete Rule Index

The full set of 71 rules across all categories:

Rule ID Severity Category
structure/missing-type-folder Warning Structure
structure/invalid-root-entry Error Structure
structure/invalid-name Error Structure
structure/too-deep Warning Structure
structure/duplicate-channel-format Error Structure
structure/no-channels Error Structure
structure/no-subdirectories Error Structure
structure/all-channels-disabled Warning Structure
structure/unrecognized-file Warning Structure
template/unknown-directive Error Template
template/undefined-variable Warning Template
template/nullable-access Warning Template
template/raw-expression Warning Template
template/thematic-break Warning Template
template/directive-in-table Warning Template
template/inline-directive Warning Template
template/unknown-attr Warning Template
template/unsupported-attr Warning Template
template/invalid-attr-value Warning Template
template/empty-directive-body Warning Template
template/action-without-link Warning Template
template/table-in-fields Warning Template
partial/not-found Error Partial
template/unknown-partial-param Warning Partial
template/missing-partial-param Info Partial
a11y/alt-text-quality Warning Accessibility
a11y/alt-text-length Info Accessibility
a11y/image-only-link Warning Accessibility
a11y/link-text-is-url Warning Accessibility
a11y/table-missing-headers Warning Accessibility
a11y/empty-heading Warning Accessibility
a11y/excessive-emoji Info Accessibility
a11y/duplicate-heading-text Info Accessibility
a11y/adjacent-duplicate-links Info Accessibility
email/missing-subject Warning Email
email/missing-preheader Warning Email
email/missing-alt Warning Email
email/heading-hierarchy Warning Email
email/link-text-quality Warning Email
email/low-contrast Warning Email
email/url-shortener Warning Email
email/text-to-image-ratio Warning Email
email/size-limit Warning Email
email/subject-length Info Email
email/preheader-length Info Email
email/spam-words Info Email
email/subject-all-caps Info Email
email/subject-excessive-punctuation Info Email
email/all-caps-body Info Email
email/missing-unsubscribe Info Email
email/font-size-minimum Info Email
sms/no-images Warning SMS
sms/no-tables Warning SMS
sms/encoding-warning Info SMS
sms/extended-gsm-chars Info SMS
sms/message-length Info SMS
sms/segment-count Warning / Info SMS
push/missing-title Warning Push
push/title-length Warning Push
push/body-length Info Push
push/payload-size Warning Push
push/too-many-actions Warning Push
whatsapp/too-many-buttons Warning WhatsApp
whatsapp/body-length Warning WhatsApp
whatsapp/header-length Warning WhatsApp
whatsapp/footer-length Warning WhatsApp
whatsapp/button-label-length Warning WhatsApp
whatsapp/cta-button-limit Warning WhatsApp
slack/block-count Warning Slack
slack/section-text-length Warning Slack
slack/actions-count Warning Slack
msteams/card-payload-size Warning MS Teams
msteams/action-count Warning MS Teams