Norq
Spec/0.1-alpha/formatting

Template Formatting Spec

Version: 0.1

Overview

norq fmt enforces a single canonical format for Norq template files. Like gofmt or black, the rules are opinionated and non-configurable. One way to format. No debates.

Fundamental invariant: formatting is purely cosmetic. A formatted template MUST compile to exactly the same output as the unformatted original. Formatting is syntactic sugar — it improves readability without changing semantics. If formatting changes compilation output, that is a bug in the formatter OR in the parser's indentation stripping.

This invariant is guaranteed by two mechanisms working together:

  1. The formatter adds indentation and normalizes whitespace
  2. The parser strips common indentation from directive bodies before Markdown parsing (see Syntax Spec § Indentation Inside Directives)

Together, these ensure: compile(format(template)) == compile(template).

Invocation

norq fmt                      # Format all templates in the project
norq fmt --check              # Check without modifying (exit 1 if unformatted)
norq fmt --config <path>      # Target a specific project

Exit codes:

  • 0 — all files formatted (or already formatted)
  • 1--check mode: one or more files need formatting
  • Non-zero — file read/write errors

Editor integration: The LSP server exposes textDocument/formatting, so "Format Document" in VS Code, Neovim, Zed, etc. uses the same rules.

File Discovery

norq fmt discovers template files by:

  1. Resolving norq.config.yaml (using --config or auto-discovery)
  2. Reading the notifications: path from config
  3. Recursively collecting all .md files under that directory
  4. Including _shared/ partial files

Only .md files are formatted. data.schema.yaml, data.samples.yaml, tests.yaml, and norq.config.yaml are not touched.

Idempotency

The formatter MUST be idempotent:

format(format(template)) == format(template)

Applying the formatter twice produces the same result as applying it once. This is critical for CI (norq fmt --check) and editor-on-save workflows — a formatted file should not change when formatted again.

Rules

Rule 1: Directive Indentation

Content inside a directive is indented by 2 spaces relative to the directive opener. The directive opener and closer are at the same indent level.

Before:

::: section {bg="#f9f9f9"}
Hello world
:::

After:

::: section {bg="#f9f9f9"}
  Hello world
:::

Rule 2: Nested Directive Indentation

Each level of directive nesting adds 2 more spaces. The :::col opener is indented relative to :::columns, and content inside :::col is indented relative to :::col.

Before:

::: columns
::: col {width="1/2"}
Left content
:::
::: col {width="1/2"}
Right content
:::
:::

After:

::: columns
  ::: col {width="1/2"}
    Left content
  :::
  ::: col {width="1/2"}
    Right content
  :::
:::

Rule 3: Blank Line Normalization

Multiple consecutive blank lines are collapsed to a single blank line.

Before:

# Title
 
 
 
Paragraph

After:

# Title
 
Paragraph

Rule 4: No Blank Lines After Directive Openers

A blank line immediately after a directive opener is removed.

Before:

::: section
 
  Hello
:::

After:

::: section
  Hello
:::

Rule 5: No Blank Lines Before Directive Closers

A blank line immediately before a ::: closer is removed.

Before:

::: section
  Hello
 
:::

After:

::: section
  Hello
:::

Rule 6: Frontmatter Preserved As-Is

Everything between the opening --- and closing --- is output unchanged. The formatter does not reorder, reindent, or normalize frontmatter content.

---
subject: "Hello: World"
preheader: "Welcome to Norq"
defaults:
  image: { padding: "0" }
styles:
  headline: { size: "4xl", weight: "bold" }
---

This is preserved exactly as written.

Rule 7: Code Blocks Preserved As-Is

Content inside fenced code blocks (``` ... ```) is output unchanged. The opening and closing fences are indented to match the current directive depth, but everything between them is preserved exactly.

Before:

::: section
```json
{
  "key": "value"
}

:::


**After:**
```markdown
::: section
  ```json
{
  "key": "value"
}

:::


The JSON content between the fences is not indented — only the fence markers
are. This preserves code formatting and prevents breakage of embedded code.

### Rule 8: `:::raw` Directive Content

Content inside `:::raw` directives is treated like content in any other
directive — lines are indented to the current depth. The raw HTML structure
itself is not reformatted.

### Rule 9: `:::else` Alignment

`:::else` is placed at the same indent level as its matching `:::if`. It acts
as both a closer (reducing indent by 1) and opener (increasing indent by 1).

**Before:**
```markdown
::: if condition
True branch
:::else
False branch
:::

After:

::: if condition
  True branch
:::else
  False branch
:::

Rule 10: Block Attributes Stay On Their Own Line

Block attributes ({key="value"} or {.style}) that are already on their own line remain on their own line. The formatter does not merge them with content or split them off.

::: section
  Headline text
  {.headline}
 
  Body text
  {align="center"}
:::

Rule 11: Content Lines Are Trimmed

Leading whitespace on content lines is replaced with the canonical indent for the current depth. Trailing whitespace is stripped.

Before:

::: section
      Overly indented
   Inconsistently indented
:::

After:

::: section
  Overly indented
  Inconsistently indented
:::

Rule 12: Trailing Newline

The formatted output always ends with exactly one newline character. No trailing blank lines.

What The Formatter Does NOT Do

The formatter is deliberately limited to whitespace and line-level operations. It does NOT:

  • Reorder frontmatter keys — key order is preserved
  • Normalize attribute syntax{size="lg"} is not changed to {size="lg" } or vice versa
  • Break long lines — no line-length limit or wrapping
  • Add or remove blank lines between block elements — it only normalizes consecutive blanks; it does not insert blank lines where none exist
  • Validate template syntax — formatting succeeds even on invalid templates (use norq lint for validation)
  • Modify non-.md files — YAML, JSON, and config files are untouched
  • Change the ::: fence style — all directives use ::: (no :::: or longer fences)

Semantic Equivalence Guarantee

This is the most important property of the formatter. Given any valid template T:

compile(T) == compile(format(T))

For every channel, with any data, the compiled output MUST be identical whether the template is formatted or not. This guarantee relies on:

  1. Indentation stripping in the parser — the parser detects common leading whitespace inside directive bodies and removes it before Markdown parsing. This means the 2-space indent added by the formatter is transparent to the compiler.

  2. Blank line normalization is semantically neutral — removing extra blank lines between ::: openers/closers and content does not change the parsed AST. Extra blank lines inside directive bodies may create empty paragraphs, but the formatter only removes blanks at structural boundaries (after openers, before closers), not between content elements.

  3. Trimming content lines — replacing arbitrary leading whitespace with canonical indent is equivalent after stripping.

If a template compiles differently after formatting, file a bug. The formatter or the parser's indent stripping is wrong.

Testing

The formatter is tested at three levels:

  1. Unit tests — 29 tests in crates/core/src/formatter.rs covering each rule, edge cases, and idempotency
  2. Fixture tests — the unit test idempotent_fixture_files verifies idempotency on all kitchen-sink fixture templates
  3. Semantic equivalence — should be verified by compiling fixture templates before and after formatting and comparing output (integration test)

CLI Output Format

Normal mode

  formatted notifications/transactional/welcome/email.md
  formatted notifications/promotional/fashion/email.md

Formatted 2 of 15 file(s).

Check mode

  needs formatting notifications/transactional/welcome/email.md
  needs formatting notifications/promotional/fashion/email.md

2 of 15 file(s) need formatting.

JSON mode (future)

Not yet implemented. When added, will follow the same _protocol envelope as other CLI commands.