Project Structure Conformance Spec
Defines the directory layout rules for Norq notification projects: project root configuration, notification type folders, directory nesting, notification discovery, notification IDs, shared partials, channel-specific partials, data files, and project configuration.
This is primarily a prose specification. The structure rules describe the
resolver and discovery system rather than the compiler, so the spec test runner
(which validates compiled output) cannot exercise most of these rules directly.
Structure violations are enforced by the resolver (crates/core/src/resolver/mod.rs)
and the structure linter (crates/core/src/linter/structure.rs).
Project Root
Every Norq project requires a norq.config.yaml file at its root. This file
anchors the project and is the entry point for all resolution.
A minimal valid project contains:
my-project/
norq.config.yaml
notifications/
system/
transactional/
promotional/
The norq.config.yaml must contain at minimum a notifications key pointing
to the notifications directory (relative to the config file):
notifications: ./notificationsConfig Discovery
The resolver locates norq.config.yaml using a two-phase strategy:
-
Upward walk: Starting from the current working directory, walk up the directory tree checking each directory for
norq.config.yaml. The first match wins. -
Downward scan: If no config is found upward, scan downward from the current directory up to 5 levels deep. Results are sorted by depth (shallowest first). When a config is found, its subtree is not recursed further.
The downward scan skips:
- Dot-prefixed directories (
.git,.vscode, etc.) - Underscore-prefixed directories (
_shared,_build, etc.) - Known non-project directories:
node_modules,target,.git,vendor,dist,build,__pycache__
The --config <path> CLI flag overrides discovery entirely, targeting a
specific norq.config.yaml or its parent directory.
Environment Variable Substitution
Values in norq.config.yaml may reference environment variables using
${VAR_NAME} syntax. Variables are substituted before YAML parsing.
Missing variables are replaced with an empty string.
providers:
resend:
config:
api_key: ${RESEND_API_KEY}Notification Type Folders
All notifications must live under one of three required type folders inside
the notifications directory. No other root-level entries are allowed except
directories prefixed with _ (e.g., _shared/).
| Folder | Purpose | Examples |
|---|---|---|
system/ |
System-generated, non-marketing notifications | Password reset, security alert, 2FA |
transactional/ |
Triggered by user actions, delivery-critical | Order shipped, welcome, invoice |
promotional/ |
Marketing and engagement communications | Weekly digest, campaign, re-engagement |
Enforcement Rules
-
All three type folders should exist. The linter emits
structure/missing-type-folder(warning) for each missing type folder. -
Any entry at the notifications root that is not one of the three type folders and does not start with
_is invalid. The resolver returnsResolveError::InvalidRootEntryand the linter emitsstructure/invalid-root-entry(error). -
Files at the notifications root are also invalid root entries. Only directories are allowed.
notifications/
system/ # valid
transactional/ # valid
promotional/ # valid
_shared/ # valid (underscore-prefixed)
marketing/ # INVALID: not a type folder
README.md # INVALID: file at root level
Directory Nesting
Within each type folder, up to 2 levels of nesting are supported for
organizing notifications into categories and subcategories. Combined with the
type folder itself and the notification directory, the maximum depth from
notifications/ is 4 levels: type / category / subcategory / notification.
Depth Levels
| Depth | Path Pattern | Structure |
|---|---|---|
| 1 | type/notification/ |
Notification directly under type |
| 2 | type/category/notification/ |
One level of categorization |
| 3 | type/category/subcategory/notification/ |
Two levels of categorization |
A notification at depth greater than 3 within a type folder (i.e., more than 4
levels from notifications/) is silently ignored by the resolver and produces
a structure/too-deep warning from the linter.
Category vs. Notification Detection
The resolver distinguishes categories from notifications by the presence of channel files (see Notification Discovery). A directory that contains at least one channel file is a notification. A directory without channel files is treated as a category or subcategory.
notifications/
transactional/
welcome/ # notification (has email.md)
email.md
sms.md
account/ # category (no channel files)
password-reset/ # notification (has email.md)
email.md
security-alert/ # notification (has email.md)
email.md
data.schema.yaml
Naming Rules
Directory names for notifications, categories, and subcategories must be
lowercase alphanumeric with hyphens: [a-z0-9]+(-[a-z0-9]+)*. The linter
emits structure/invalid-name (error) for directories that violate this
pattern.
Valid names: welcome, order-shipped, password-reset, q4-2024
Invalid names: Order_Shipped (uppercase, underscores), welcome! (special
characters), my notification (spaces)
Notification Directory Contents
A notification directory may contain:
- Channel files:
email.md,sms.md,slack.md,push.md,whatsapp.md,msteams.md(Markdown mode) orslack.json,msteams.json(Native mode) - Data files:
data.schema.yaml,data.samples.yaml,tests.yaml
A notification directory must not contain subdirectories. The linter emits
structure/no-subdirectories (warning) for any subdirectory found inside a
notification directory (excluding dot-prefixed directories which are silently
ignored).
Unrecognized files inside a notification directory produce
structure/unrecognized-file (warning). Dot-prefixed files (e.g., .DS_Store)
are silently ignored.
Notification Discovery
The resolver discovers notifications by recursively scanning type folders and checking each subdirectory for the presence of channel files.
Channel File Detection
A directory is considered a notification if it contains at least one file matching any of these patterns:
| Filename | Channel | Mode |
|---|---|---|
email.md |
Markdown | |
email.json |
Native | |
sms.md |
SMS | Markdown |
sms.json |
SMS | Native |
slack.md |
Slack | Markdown |
slack.json |
Slack | Native |
push.md |
Push | Markdown |
push.json |
Push | Native |
whatsapp.md |
Markdown | |
whatsapp.json |
Native | |
msteams.md |
MS Teams | Markdown |
msteams.json |
MS Teams | Native |
Each channel supports exactly one format per notification. If both .md and
.json exist for the same channel (e.g., slack.md and slack.json), the
resolver returns ResolveError::DuplicateChannelFormat and the linter emits
structure/duplicate-channel-format (error).
Channel Independence
Each channel has its own template file. Channels are authored and compiled independently. They share the same data contract (schema and samples) and template syntax, but are not generated from each other. A notification may have any subset of channels.
Enabled/Disabled Channels
Channels can be individually disabled without removing the file:
- Markdown mode: Set
enabled: falsein YAML frontmatter. - Native mode: Set
"$norq": { "enabled": false }in the JSON root.
A channel defaults to enabled: true if the flag is absent or cannot be
parsed. If all channels in a notification are disabled, the linter emits
structure/all-channels-disabled (warning). If a notification has zero channel
files, the linter emits structure/no-channels (error).
Discovery Traversal
The resolver traverses from each type folder:
- Read all entries in the directory.
- Skip entries starting with
_or.. - Skip non-directory entries (files directly in category folders are ignored).
- For each subdirectory, check if it is a notification (contains channel files).
- If it is a notification, record it. Do not recurse into it.
- If it is not a notification (category/subcategory), recurse if depth < 2 within the type folder. Directories at depth >= 2 that are not notifications are not traversed further, and any notifications inside them are silently ignored.
Notification IDs
A notification ID is derived from its path relative to the notifications/
directory. The ID always includes the type prefix. Forward slashes separate
path segments regardless of the host OS.
ID Derivation
| Filesystem Path | Notification ID |
|---|---|
notifications/transactional/welcome/ |
transactional/welcome |
notifications/transactional/account/password-reset/ |
transactional/account/password-reset |
notifications/promotional/campaigns/summer-sale/welcome-offer/ |
promotional/campaigns/summer-sale/welcome-offer |
notifications/system/security-alert/ |
system/security-alert |
Parsed Fields
When resolving a notification, the ID is split into segments to populate the
NotificationBundle struct:
| Segments | notification_type |
category |
subcategory |
|---|---|---|---|
| 2 | First segment | None |
None |
| 3 | First segment | Second | None |
| 4+ | First segment | Second | Third |
Examples:
transactional/welcome-- type=Transactional, category=None, subcategory=Nonetransactional/account/password-reset-- type=Transactional, category=Some("account"), subcategory=Nonepromotional/campaigns/summer-sale/welcome-offer-- type=Promotional, category=Some("campaigns"), subcategory=Some("summer-sale")
The notification name is always the last segment of the ID. Intermediate segments between the type and the notification are categories/subcategories.
ID Validation
The first segment of a notification ID must be a valid type name (system,
transactional, or promotional). Attempting to resolve an ID with an invalid
type prefix returns ResolveError::InvalidNotificationType.
Notification IDs are returned sorted lexicographically by
list_notifications().
Shared Partials
Partials are reusable template fragments stored in _shared/ directories.
They are included in templates using {{> partial-name}} syntax.
Valid _shared/ Locations
_shared/ directories can exist at any level of the directory hierarchy:
| Location | Scope |
|---|---|
notifications/_shared/ |
Global |
notifications/transactional/_shared/ |
Type |
notifications/transactional/account/_shared/ |
Category |
Note: Notification directories themselves do not contain a _shared/
subdirectory (subdirectories inside notifications are invalid per the structure
rules). However, the partial resolution system starts its walk-up from the
notification directory itself, so _shared/ directories at any ancestor level
are accessible.
Walk-Up Resolution
Partial resolution uses a hierarchical walk-up strategy. When a template
includes {{> my-partial}}, the resolver searches for the partial file
starting from the notification directory and walking up to the notifications
root. The first match wins.
Resolution order for a notification at
notifications/transactional/account/password-reset/:
notifications/transactional/account/password-reset/_shared/my-partial.md-- (would be found here if it existed, but notification dirs should not have_shared/subdirectories)notifications/transactional/account/_shared/my-partial.mdnotifications/transactional/_shared/my-partial.mdnotifications/_shared/my-partial.md
Closer scopes shadow broader scopes. A partial at category scope overrides one at global scope with the same name.
Partial File Naming
Partials are plain .md files by default. The partial name in {{> name}}
maps directly to _shared/name.md.
_shared/
email-header.md # included as {{> email-header}}
email-footer.md # included as {{> email-footer}}
product-card.md # included as {{> product-card}}
Nested Partials
Partials can include other partials. Resolution is recursive with cycle detection. If a partial includes itself (directly or transitively), the circular reference is replaced with an HTML comment:
<!-- partial cycle: my-partial -->Parameterized Partials
Partials accept parameters using key=value or key="literal" syntax:
{{> product-card name=item.name price=item.price title="Featured"}}
Inside the partial, parameters are referenced with {{@key}} syntax:
## {{@title}}
**{{@name}}** -- ${{@price}}Parameter value types:
- Variable path (
key=item.name): The value is resolved from the data context at compile time.{{@key}}expands to{{item.name}}. - Literal string (
key="Featured"): The value is inserted directly.{{@key}}expands to the literal text. With pipes,{{@key | upper}}expands to{{"Featured" | upper}}.
If a parameter key is not found, the {{@key}} reference is left as-is in
the output.
Unresolved Partials
If a partial file cannot be found at any scope in the hierarchy, the
{{> partial-name}} reference is left as-is in the output (double-braced:
{{> partial-name}}). This allows the linter to detect and report missing
partials separately.
Channel-Specific Partials
Partials can have channel-native format variants that take priority over the
generic .md version. When resolving {{> my-partial}}, the resolver checks
for a channel-native format first, then falls back to .md.
Native Format Extensions
| Channel | Native Extension | Format |
|---|---|---|
.mjml |
Raw MJML | |
| Slack | .blocks.json |
Slack Block Kit |
| MS Teams | .card.json |
Adaptive Card |
| SMS | (none) | Markdown only |
| Push | (none) | Markdown only |
| (none) | Markdown only |
Resolution Priority
For a partial {{> footer}} in an email template, the resolver checks each
_shared/ directory (in walk-up order) for:
_shared/footer.mjml-- if found, treated as raw MJML_shared/footer.md-- if found, treated as Markdown
For Slack: _shared/footer.blocks.json then _shared/footer.md.
For MS Teams: _shared/footer.card.json then _shared/footer.md.
For SMS, Push, and WhatsApp: only _shared/footer.md is checked.
Native Partial Handling
Markdown partials are expanded inline during the partial resolution phase (before parsing). Native-format partials are handled differently:
- In the hierarchical resolver (without native support), native partials are
left as
{{> name}}markers for the compiler to handle in a second pass. - In the hierarchical resolver with native support, native partials are
replaced with unique marker comments (
<!-- NORQ_NATIVE_PARTIAL_{id} -->) and their content is stored in a side-channel map. The compiler injects them into the AST asNativePartialnodes during post-parse processing.
Data Files
Each notification directory may contain data files that define the template's
data contract and test fixtures. These are described briefly here; see
schema.md and testing.md for full specifications.
data.schema.yaml
Defines the expected data shape using JSON Schema (Draft 2020-12) in YAML format. The schema is used by the linter to validate that templates only reference defined properties and to check nullability of variables used in conditional expressions.
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user, action_url]
properties:
user:
type: object
required: [first_name, email]
properties:
first_name: { type: string }
email: { type: string, format: email }
action_url: { type: string, format: uri }The schema file is optional. Without it, the linter cannot perform data path validation.
data.samples.yaml
Provides named sample data objects for previewing and testing templates. Each
sample has a name and a data object matching the schema.
samples:
- name: "Full data"
data:
user:
first_name: "Alice"
email: "alice@example.com"
action_url: "https://app.example.com/dashboard"
- name: "Minimal"
data:
user:
first_name: "Eve"
email: "eve@example.com"
action_url: "https://app.example.com/start"Samples are used by norq compile (with --sample), norq preview, and
norq test. The samples file is optional.
tests.yaml
Defines per-notification test assertions that norq test executes. Tests
specify a channel, a sample, and assertions about the compiled output.
tests:
- name: "Email renders without errors"
channel: email
sample: "Full data"
assert:
diagnostics: { errors: 0 }
- name: "SMS within segment limit"
channel: sms
sample: "Minimal"
assert:
sms_segments: { lte: 2 }Tests can also use all_channels: true and all_samples: true to run
assertions across all combinations. The tests file is optional.
Configuration
The norq.config.yaml file configures the project. All keys except
notifications are optional.
Required Fields
| Key | Type | Description |
|---|---|---|
notifications |
string | Path to the notifications directory (relative) |
Optional Fields
| Key | Type | Description |
|---|---|---|
providers |
map of provider configs | Delivery provider configurations |
routing |
map of channel->provider | Maps channels to providers |
theme |
EmailTheme object | Email theme tokens (colors, typography, layout) |
fonts |
array of FontConfig | Web font @font-face declarations |
codegen |
array of objects | Code generation targets |
registries |
map of scope->registry | Partial registry sources |
Theme
The theme key controls email appearance. All keys use camelCase -- snake_case
keys are silently ignored. See the email spec for the full list of theme tokens.
theme:
brandColor: "#6d28d9"
headingColor: "#1e1b4b"
bodyColor: "#374151"
backgroundColor: "#f9fafb"
contentColor: "#ffffff"
cardColor: "#f3f4f6"
buttonColor: "#6d28d9"
buttonTextColor: "#ffffff"
fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
fontSize: "16px"
lineHeight: "1.6"
contentWidth: "600px"
codeTheme: "base16-ocean.dark"
darkMode: "off"The dark sub-key under theme provides color overrides for darkMode: auto.
Only color keys are valid in the dark block -- typography keys (fontFamily,
fontSize, lineHeight, contentWidth) are not supported.
Providers
Each provider entry has a name and a config object. Provider API keys should use environment variable substitution to avoid committing secrets:
providers:
resend:
config:
api_key: ${RESEND_API_KEY}
console:
config: {}Routing
Maps each channel to a provider name:
routing:
email: resend
sms: twilio
slack: console
push: console
whatsapp: console
msteams: consoleFonts
Declares web fonts that compile to CSS @font-face rules in email output:
fonts:
- family: Inter
weight: 400
style: normal
src:
- url: https://fonts.gstatic.com/s/inter/v13/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa2JL7SUc.woff2
format: woff2Registries
Registries are sources for installing shared partials via norq add. Each
entry maps a scope prefix to a URL template containing a {name} placeholder.
Registries support optional authentication headers:
registries:
"@acme": "https://registry.acme.com/r/{name}.json"
"@private":
url: "https://internal.company.com/r/{name}.json"
headers:
Authorization: "Bearer ${REGISTRY_TOKEN}"Complete Example
A fully-featured project structure:
my-project/
norq.config.yaml
notifications/
_shared/ # global partials
email-header.md
email-footer.md
email-footer.mjml # native MJML variant (email only)
slack-footer.md
system/
password-reset/
email.md
sms.md
transactional/
_shared/ # type-scoped partials
receipt-table.md
welcome/
email.md
sms.md
slack.md
push.md
whatsapp.md
msteams.md
data.schema.yaml
data.samples.yaml
tests.yaml
account/ # category
_shared/ # category-scoped partials
account-header.md
password-reset/
email.md
sms.md
data.schema.yaml
data.samples.yaml
security-alert/
email.md
data.schema.yaml
data.samples.yaml
promotional/
weekly-tips/
email.md
sms.md
data.schema.yaml
data.samples.yaml
campaigns/ # category
summer-sale/ # subcategory
welcome-offer/
email.md
push.md
data.schema.yaml
data.samples.yaml
Notification IDs in this project:
system/password-resettransactional/welcometransactional/account/password-resettransactional/account/security-alertpromotional/weekly-tipspromotional/campaigns/summer-sale/welcome-offer
Partial resolution for transactional/account/password-reset:
notifications/transactional/account/_shared/(category scope)notifications/transactional/_shared/(type scope)notifications/_shared/(global scope)
Diagnostic Rules Summary
Structure-related lint rules and their severity:
| Rule | Severity | Trigger |
|---|---|---|
structure/missing-type-folder |
Warning | Required type folder missing |
structure/invalid-root-entry |
Error | Non-type-folder entry at notifications root |
structure/invalid-name |
Error | Directory name violates [a-z0-9]+(-[a-z0-9]+)* |
structure/too-deep |
Warning | Notification nested beyond max depth |
structure/duplicate-channel-format |
Error | Both .md and .json for same channel |
structure/no-subdirectories |
Warning | Subdirectory inside a notification directory |
structure/unrecognized-file |
Warning | Unknown file in a notification directory |
structure/no-channels |
Error | Notification has zero channel files |
structure/all-channels-disabled |
Warning | All channels have enabled: false |
Error Reference
Resolver errors that can occur during project resolution:
| Error | Condition |
|---|---|
norq::config_not_found |
No norq.config.yaml found in any direction |
norq::config_read_error |
Config file exists but cannot be read or parsed |
norq::notifications_dir_not_found |
The notifications path does not exist |
norq::notification_not_found |
Requested notification ID does not exist on disk |
norq::duplicate_channel_format |
Both .md and .json exist for one channel |
norq::invalid_root_entry |
Non-allowed entry at notifications root level |
norq::invalid_notification_type |
First ID segment is not system/transactional/promotional |