Norq
Spec/0.1-alpha/structure

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: ./notifications

Config Discovery

The resolver locates norq.config.yaml using a two-phase strategy:

  1. Upward walk: Starting from the current working directory, walk up the directory tree checking each directory for norq.config.yaml. The first match wins.

  2. 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 returns ResolveError::InvalidRootEntry and the linter emits structure/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) or slack.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 Email Markdown
email.json Email 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 WhatsApp Markdown
whatsapp.json WhatsApp 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: false in 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:

  1. Read all entries in the directory.
  2. Skip entries starting with _ or ..
  3. Skip non-directory entries (files directly in category folders are ignored).
  4. For each subdirectory, check if it is a notification (contains channel files).
  5. If it is a notification, record it. Do not recurse into it.
  6. 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=None
  • transactional/account/password-reset -- type=Transactional, category=Some("account"), subcategory=None
  • promotional/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/:

  1. notifications/transactional/account/password-reset/_shared/my-partial.md -- (would be found here if it existed, but notification dirs should not have _shared/ subdirectories)
  2. notifications/transactional/account/_shared/my-partial.md
  3. notifications/transactional/_shared/my-partial.md
  4. notifications/_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
Email .mjml Raw MJML
Slack .blocks.json Slack Block Kit
MS Teams .card.json Adaptive Card
SMS (none) Markdown only
Push (none) Markdown only
WhatsApp (none) Markdown only

Resolution Priority

For a partial {{> footer}} in an email template, the resolver checks each _shared/ directory (in walk-up order) for:

  1. _shared/footer.mjml -- if found, treated as raw MJML
  2. _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 as NativePartial nodes 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: console

Fonts

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: woff2

Registries

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-reset
  • transactional/welcome
  • transactional/account/password-reset
  • transactional/account/security-alert
  • promotional/weekly-tips
  • promotional/campaigns/summer-sale/welcome-offer

Partial resolution for transactional/account/password-reset:

  1. notifications/transactional/account/_shared/ (category scope)
  2. notifications/transactional/_shared/ (type scope)
  3. 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