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:
- The formatter adds indentation and normalizes whitespace
- 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—--checkmode: 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:
- Resolving
norq.config.yaml(using--configor auto-discovery) - Reading the
notifications:path from config - Recursively collecting all
.mdfiles under that directory - 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
ParagraphAfter:
# Title
ParagraphRule 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 lintfor validation) - Modify non-
.mdfiles — 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:
-
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.
-
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. -
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:
- Unit tests — 29 tests in
crates/core/src/formatter.rscovering each rule, edge cases, and idempotency - Fixture tests — the unit test
idempotent_fixture_filesverifies idempotency on all kitchen-sink fixture templates - 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.