Editor Setup
Norq includes a Language Server Protocol (LSP) server that provides editor intelligence for .md and .json template files inside notification directories.
Features
| Feature | Description |
|---|---|
| Diagnostics | Real-time lint errors and warnings as you type |
| Completions | Variable names from data.schema.yaml, directive names, pipe names |
| Hover | Type info and descriptions from the schema on hover |
| Null safety | Warnings when nullable fields are used without :::if guards |
| Code actions | Quick fixes (e.g., wrap nullable access in :::if guard) |
| Deliverability | Real-time email deliverability and WCAG contrast checking |
| Document formatting | Format Document applies canonical indentation (same rules as norq fmt) |
| On-type formatting | Auto-indent on Enter inside directives (2-space indent, dedent on ::: closer) |
VS Code
Install the Norq extension from the VS Code marketplace. It bundles the LSP server and provides:
- Syntax highlighting for
.mdtemplate files - LSP features (diagnostics, completions, hover, code actions)
- Inline preview panel
- Channel switcher in the status bar
The extension automatically detects projects with norq.config.yaml and activates.
Neovim
With native LSP (recommended)
vim.api.nvim_create_autocmd('FileType', {
pattern = 'markdown',
callback = function()
vim.lsp.start({
name = 'norq',
cmd = { 'norq', 'lsp' },
root_dir = vim.fs.root(0, 'norq.config.yaml'),
})
end,
})With nvim-lspconfig
local lspconfig = require("lspconfig")
lspconfig.norq.setup({
cmd = { "norq", "lsp" },
filetypes = { "markdown" },
root_dir = lspconfig.util.root_pattern("norq.config.yaml"),
})With coc.nvim
Add to coc-settings.json:
{
"languageserver": {
"norq": {
"command": "norq",
"args": ["lsp"],
"filetypes": ["markdown"],
"rootPatterns": ["norq.config.yaml"]
}
}
}Vim
Vim does not have a built-in LSP client. Use the vim-lsp plugin.
Add to your .vimrc:
if executable('norq')
au User lsp_setup call lsp#register_server({
\ 'name': 'norq',
\ 'cmd': {server_info->['norq', 'lsp']},
\ 'allowlist': ['markdown'],
\ 'root_uri': {server_info->lsp#utils#path_to_uri(
\ lsp#utils#find_nearest_parent_file_directory(
\ lsp#utils#get_buffer_path(), 'norq.config.yaml'))},
\ })
endifHelix
Add to languages.toml:
[[language]]
name = "markdown"
language-servers = ["norq-lsp"]
[language-server.norq-lsp]
command = "norq"
args = ["lsp"]Zed
Install the Norq extension from the Zed extension registry, or load the zed/ directory as a dev extension:
- Open Zed
- Go to Extensions > Install Dev Extension
- Select the
zed/directory in the norq repo
The extension automatically finds norq on your PATH and launches the LSP.
Sublime Text
Install the LSP package, then add to LSP settings:
{
"clients": {
"norq": {
"command": ["norq", "lsp"],
"selector": "text.html.markdown",
"initializationOptions": {},
"settings": {}
}
}
}Emacs (lsp-mode)
(with-eval-after-load 'lsp-mode
(add-to-list 'lsp-language-id-configuration '(markdown-mode . "markdown"))
(lsp-register-client
(make-lsp-client
:new-connection (lsp-stdio-connection '("norq" "lsp"))
:activation-fn (lsp-activate-on "markdown")
:server-id 'norq-lsp)))How the LSP works
The LSP server starts via norq lsp on stdin/stdout. It:
- Discovers the project root by finding
norq.config.yaml - Loads all
data.schema.yamlfiles for completion and type info - Watches for file changes to update diagnostics in real time
- Provides scoped completions based on the current context (directive names after
:::, variable paths inside{{, pipe names after|)
The LSP only activates for files inside a notifications/ directory that is part of a Norq project.
Completions
The LSP provides context-aware completions:
- After
:::– directive names (header,footer,action,highlight,centered,table, etc.) - After
:::directive(space, no brace) –{attr=}attribute snippets for the directive under the cursor - Inside
{{– variable paths fromdata.schema.yamlwith dot-path traversal - After
:::ifand:::each– variable path completions fromdata.schema.yaml - After
|inside{{– pipe names with argument snippets (e.g.,truncate $1,currency "$1") - Inside frontmatter – known frontmatter keys for the current channel
- After
{on a directive opening line – context-aware attribute completions (align=,bg=,color=,padding=,size=,weight=,spacing=, plus directive-specific attrs likeurl=for:::hero,width=/valign=for:::col) - After
{{>– partial template names from all_shared/directories in the hierarchy (local, category, type, global), with scope labels - After
{{> partial-name– parameter names (key=) extracted from the partial’s{{@key}}references - After
{{@inside a_shared/file – parameter names used in the current partial - Inside
{...}aliases inbrand.yaml– brand section names (colors,spacing,radii,typography,fonts); after the dot, the actual token names from the in-memory document. Tolerates unterminated strings during typing (regex fallback when YAML is too broken to parse)
Completion trigger characters
The LSP registers the following characters as completion triggers: :, {, >, |, ., (space), @.
The { character triggers context-aware attribute completions on directive opening lines. After typing ::: hero {, the LSP suggests attributes valid for that directive (url=, align=, bg=, color=, padding=, size=, weight=, spacing=). Directive-specific attrs like width= and valign= appear only for :::col. The > and @ characters trigger partial name and parameter completions inside {{ }}.
Directive completions
The following directives appear in completions after ::::
| Directive | Description |
|---|---|
header |
Branded page header |
footer |
Footer with small text |
action |
Call-to-action button(s) |
callout |
Highlighted callout box |
hero |
Full-width image or banner |
fields |
Key-value data table |
media |
Image, video, or file link |
columns |
Multi-column layout |
list |
Ordered or unordered list |
highlight |
Dark card with brand color background |
centered |
Center-aligned text block |
table |
Iterable data table (rows from array variable) |
section |
Single-column section (shorthand for :::columns + :::col) |
col |
Column inside :::columns |
if / else / each |
Control flow |
raw |
Raw passthrough block |
Directive attribute completions
After typing { on a directive opening line, the LSP offers context-aware attribute completions. The offered attributes depend on which directive is being edited:
Universal attributes (all directives):
align=–left,center,rightbg=–brand,card,highlight,#hexcolor=–brand,muted,danger,success,warning,#hexpadding=–none,compact,normal,spacioussize=–xs,sm,md,lg,xl,2xl,3xl,4xlweight=–light,normal,medium,semibold,boldspacing=–tight,normal,relaxed,loose
Directive-specific attributes (only shown for the relevant directive):
url=–:::heroonlywidth=,valign=–:::colonlyvariant=–:::actiononlyflush,mobile=–:::columns,:::sectiononly
Example: typing ::: col { triggers suggestions for width=, valign=, bg=, and all universal attributes.
Diagnostics
Diagnostics appear in real time as you edit:
- Error: Parse failures, missing required frontmatter
- Warning: Nullable access without
:::ifguard or| defaultpipe, raw expressions - Info: Native JSON mode suggestion, unused variables
Code Actions
The LSP offers quick fixes triggered by diagnostics.
Wrap in :::if guard
When the linter warns about nullable access (template/nullable-access), the LSP offers a one-click fix to wrap the expression in an :::if guard. You can also silence the warning by using a | default pipe instead.
Before:
Hello {{user.last_name}}Diagnostic: Variable 'user.last_name' is nullable -- wrap in :::if guard or use | default "..."
Fix with code action (wraps in :::if guard):
:::if user.last_name
Hello {{user.last_name}}
:::Fix with | default pipe:
Hello {{user.last_name | default "friend"}}In VS Code, click the lightbulb or press Ctrl+. / Cmd+. on the warning to apply.
Formatting
The LSP provides two formatting capabilities:
Document formatting
Trigger Format Document (e.g., Shift+Alt+F in VS Code) to format the entire template with canonical indentation and spacing. Uses the same rules as norq fmt:
- 2-space indent inside directives, +2 for each nesting level
- One blank line between block-level elements
- No trailing blank lines before
:::closers - Frontmatter and code blocks preserved as-is
On-type formatting
When you press Enter inside a template, the LSP auto-adjusts indentation:
- After a
:::directiveopener: indent +2 spaces - After a
:::closer: dedent to match the opener’s level - After
:::else: indent +2 spaces (same as opener) - Regular lines: maintain previous line’s indent level
This keeps directive bodies consistently indented as you type.
Config file support
The LSP provides intelligence for norq.config.yaml in addition to template files:
- Diagnostics – unknown keys, missing required fields, invalid channel names in
routing - Completions – top-level keys (
notifications,brand,providers,routing,email,codegen,strict), brand-token sections insidebrand.yaml(tokens.colors,tokens.typography,tokens.spacing,tokens.radii,styles,defaults,modes,fonts), channel names in routing values - Hover – descriptions and default values for each config key
The config schema is defined in crates/core/src/schema/config.rs. Adding a new config key means adding one ConfigField entry.