Compile errors
lttr.Compile checks everything about a template that doesn't depend on data: the Comark source, the frontmatter, every module and its attributes, every merge tag and binding against your Go type, and the family rules. It reports every problem it finds at once, each with the template name, line and column.
Read an error
Each error is one line: name:line:col: message. Lines and columns start at 1, and columns count bytes. When there are several, Compile joins them with errors.Join, so printing the error prints one per line:
auth.reset:7:15: unknown name "cod"
auth.reset:11:1: notice: tone must be "warn"
A few things decide what you see:
- A parse error stops early. If the Comark itself can't be parsed (an unclosed frontmatter, an invalid attribute list, bad props YAML, invalid UTF-8), you get that one error, the earliest in the file.
- Template errors come before family errors. The family rules (
from,issue,legal,footer.*, forbidden modules) run only once the template compiles on its own. Fix the first batch and the family errors appear on the next compile (D28). - Module errors point at the module. An unknown attribute or a wrong count is reported at the first
:of the component, not at the attribute. - Exact duplicates are dropped.
Handle errors in Go
Every positioned error is an *ir.Error:
type Error struct {
Name string // template (or copy) name
Pos Pos // Pos{Line, Col}, 1-based
Msg string
}
errors.As finds the first one. To walk all of them, unwrap the joined error:
package main
import (
"errors"
"fmt"
"net/mail"
"github.com/sulv-io/lttr"
"github.com/sulv-io/lttr/ir"
)
type Reset struct {
Code string `json:"code"`
ExpiresIn string `json:"expires_in"`
}
var families = lttr.NewRegistry(lttr.Family{
Name: "security",
From: mail.Address{Name: "Acme Security", Address: "security@acme.example"},
Stream: lttr.Security,
Header: lttr.H1,
Footer: lttr.F2,
})
const src = `---
template: auth.reset
family: security
subject: Your reset code
---
::code{value="{{cod}}"}
It expires in {{expires_in}}.
::
::notice{tone="red"}
Didn't ask for this? You can ignore this email.
::
`
func main() {
_, err := lttr.Compile[Reset](families, "auth.reset", []byte(src))
if err == nil {
return
}
// The whole error: one "name:line:col: message" per line.
fmt.Println(err)
// Each joined error is an *ir.Error with its own position.
errs := []error{err}
if j, ok := err.(interface{ Unwrap() []error }); ok {
errs = j.Unwrap()
}
for _, e := range errs {
var ce *ir.Error
if errors.As(e, &ce) {
fmt.Printf("line %d, col %d: %s\n", ce.Pos.Line, ce.Pos.Col, ce.Msg)
}
}
}
auth.reset:7:15: unknown name "cod"
auth.reset:11:1: notice: tone must be "warn"
line 7, col 15: unknown name "cod"
line 11, col 1: notice: tone must be "warn"
Two errors have no position, because they are about the call rather than the template: lttr: template data must be a struct, got string when T is not a struct, and lttr: nil registry.
MustCompile panics with the same joined error. Use it for package-level templates, so a broken template stops the service at startup, and Compile when the source comes from somewhere you don't control.
Read warnings
Warnings never fail a compile. They are on the compiled template, formatted name:line:col: warning: message, and are also copied into every rendered Message.Warnings:
| Warning | Cause |
|---|---|
frontmatter: no preheader | The template has no preheader. The inbox then previews the first text of the email. |
letter: more than 4 paragraphs | A letter (or a run of loose Markdown) has five or more paragraphs. Split it, or move detail into meta, a table or a notice. |
malformed merge tag "{{ Name }}" | Something starts with {{ but isn't a valid tag, so it is sent as literal text. Usually an upper-case letter, a missing brace, or a bare | in a table cell. |
func TestTemplatesHaveNoWarnings(t *testing.T) {
for _, w := range orderShipped.Meta().Warnings {
t.Error(w) // e.g. order.shipped:1:1: warning: frontmatter: no preheader
}
}
Warnings are produced only when the template compiles. A template with errors reports the errors alone.
Fix common errors
The messages below are copied from the compiler; quoted names and numbers change with your template.
Syntax
| Message | Cause and fix |
|---|---|
frontmatter: missing closing --- | The frontmatter has no closing --- line. |
duplicate attribute "a" | The same key twice in one {…}. |
binding :order needs a value | A :key with no ="path". |
props: did not find expected ',' or ']' | A YAML error in a props block, reported on its line with a props: prefix. |
props: given twice | Both a --- props block and a yaml [props] fence. |
slots are reserved | A #name line inside a component. Remove it, or add a space for a heading: # Name. |
raw html is not allowed | An HTML tag or block. Use modules, spans and Markdown. |
images are not allowed in prose; use ::image |  in text. Use ::image, ::map or a tile's image. |
strikethrough is not allowed | ~~text~~. |
link titles are not supported | [text](url "title"). Drop the title. |
attributes are only allowed on spans and inline components | {…} after a link, emphasis or code span. Wrap it in a span: [`code`]{.small}. |
use ::divider instead of --- | A Markdown rule between modules. |
unknown span class ".red" | Span classes are muted, teal, mono, small and bold. |
unknown inline component ":note" | Only :button and :link exist. |
Frontmatter
| Message | Cause and fix |
|---|---|
frontmatter: missing "subject" | A required key (template, family, subject) is missing. Reported at 1:1. |
frontmatter: template "payout" does not match name "payout.sent" | template must equal the name passed to Compile. |
frontmatter: unknown key "colour" | Not one of the frontmatter keys. Check the spelling. |
frontmatter: "legal" must be a boolean | A value of the wrong YAML type. Also must be a string and "footer" must be a mapping. |
frontmatter: "footer.note" must be one paragraph | footer.note, footer.reason and footer.optout hold one paragraph of Markdown. |
unknown placeholder "{sitee}" | A footer.* value uses a placeholder that is not in lttr.Vars. |
Modules
| Message | Cause and fix |
|---|---|
unknown module "stats" | Not a module name or alias. See the catalogue. |
letter: unknown attribute "x" | The module doesn't read that key, or it is an #id or .class. |
statement: "eyebrow" set twice | The same key as an attribute and in the props block. |
statement: tone must be "orange" | An enumerated value outside its options. |
image: "height" must be an integer | A number attribute that doesn't parse. |
figure: missing "amount" | A required attribute is missing. |
letter: h1 is not allowed here | A block the module doesn't take. Loose Markdown is a letter, which allows only ### headings: put the big heading in a statement. If the heading is inside a ::statement, its opener probably has an unclosed quote, which turns the opener into text. |
statement: at most one heading | A statement has one heading. |
notice: 1 or 2 paragraphs, got 3 | A content count outside the module's range; see Modules. |
breakdown: table must have 2 columns, has 3 | A table wider than the module allows (compare 3, table 4). |
breakdown: expected one table | A table module's content is exactly one GFM table. |
divider: content is not allowed | The module takes no content. |
image must directly follow a statement | Move the ::image right after a ::statement. |
more than one :button in the template | Keep one :button; make the rest :link. |
letter: :button is not allowed; use :link | Buttons go in a statement or an actions module. |
:button must stand alone in its paragraph | A button sharing a paragraph with text. Put it on its own line. |
:link: missing href | An action without href. Also :button: missing label without a label. |
progress: current "Shipped" is not a step | A literal current must be one of steps, or its 1-based number. |
products: use :items or :::product children, not both | Pick one source of tiles. |
Merge tags and bindings
| Message | Cause and fix |
|---|---|
unknown name "otp_cod" | The first segment is not a field of your type or of Recipient. Usually a typo, or a field without the JSON tag you expected. |
unknown field "nope" in main.Driver | A later segment doesn't exist on that struct. |
{{driver}} is main.Driver, not a scalar | A tag must end at a string, an integer or a fmt.Stringer. Point it at a field. |
cannot descend into data.Money at "total" | A path continues past a scalar, a slice or a non-string-keyed map. |
ambiguous field "name" in main.T | Two embedded structs promote the same name. Rename one or add JSON tags that differ. |
receipt: :order is string, want data.Receipt | A binding must resolve to exactly the module's type. |
receipt: missing binding ":order" | A required binding is missing. |
Families
| Message | Cause and fix |
|---|---|
frontmatter: unknown family "nope" | family is not in the registry passed to Compile. |
module "image" is not allowed in family "security" | The family lists this module in Forbidden. |
frontmatter: family "b2b" requires "from" | The family has FromFrontmatter; add from. |
frontmatter: "from" is only allowed for families that take it | Remove from: the family has a fixed sender. |
frontmatter: family "newsletter" requires "issue" | H3 families need issue for the masthead. |
frontmatter: "issue" is only allowed for families with a masthead | Remove issue. |
frontmatter: family "buyer" has no legal variant | legal: true needs a family with a Legal override. |
frontmatter: "footer.reason" is not used by the footer of family "buyer" | That footer doesn't show this override. See Check what the family accepts. |
Errors that depend on data happen at render time instead: a tag with no value and no fallback (no value for {{order_id}}), a merge value in a URL host that isn't a single DNS label preceded by // or . and followed by . (merge value {{shop}} cannot form part of a url host — a missing optional value there is the same error, not dropped), an invalid URL, an empty subject. They are *ir.Error values in the same format; see Config and the renderer.
Lint from the command line
lttr lint compiles template files and prints errors as file:line:col: message and warnings as file:line:col: warning: message. It exits 1 when any file has an error. For example, a copy of 03-password-reset.md with a typo in a merge tag, a bad tone and no preheader:
go run ./cmd/lttr lint 03-password-reset.md
03-password-reset.md:13:15: unknown name "otp_cod"
03-password-reset.md:17:1: notice: tone must be "warn"
With the two errors fixed, the same file compiles and lint prints the warning, exiting 0:
03-password-reset.md:1:1: warning: frontmatter: no preheader
lttr lint compiles each file against the data type of the Quiet example its template: key names, so it works on copies of the nine example templates (unknown template "x" (no data type registered) otherwise). With no files, it lints the nine embedded examples. For your own templates, compile them in a test: a package-level MustCompile fails go test on any error. See CLI and Goldens and testing.Next steps
- Comark syntax for the rules behind the syntax errors.
- Merge tags and bindings for path resolution.
- Live preview, which shows compile errors in the browser as you edit.