Authoring

Compile errors

What lttr.Compile reports, how to read it in Go, the warnings, and the common errors with their fixes.

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:

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

ir/errors.go
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:

main.go
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)
        }
    }
}
Output
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:

WarningCause
frontmatter: no preheaderThe template has no preheader. The inbox then previews the first text of the email.
letter: more than 4 paragraphsA 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.
templates_test.go
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

MessageCause and fix
frontmatter: missing closing ---The frontmatter has no closing --- line.
duplicate attribute "a"The same key twice in one {…}.
binding :order needs a valueA :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 twiceBoth a --- props block and a yaml [props] fence.
slots are reservedA #name line inside a component. Remove it, or add a space for a heading: # Name.
raw html is not allowedAn HTML tag or block. Use modules, spans and Markdown.
images are not allowed in prose; use ::image![alt](src) 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

MessageCause 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 booleanA value of the wrong YAML type. Also must be a string and "footer" must be a mapping.
frontmatter: "footer.note" must be one paragraphfooter.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

MessageCause 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 twiceThe 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 integerA number attribute that doesn't parse.
figure: missing "amount"A required attribute is missing.
letter: h1 is not allowed hereA 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 headingA statement has one heading.
notice: 1 or 2 paragraphs, got 3A content count outside the module's range; see Modules.
breakdown: table must have 2 columns, has 3A table wider than the module allows (compare 3, table 4).
breakdown: expected one tableA table module's content is exactly one GFM table.
divider: content is not allowedThe module takes no content.
image must directly follow a statementMove the ::image right after a ::statement.
more than one :button in the templateKeep one :button; make the rest :link.
letter: :button is not allowed; use :linkButtons go in a statement or an actions module.
:button must stand alone in its paragraphA button sharing a paragraph with text. Put it on its own line.
:link: missing hrefAn action without href. Also :button: missing label without a label.
progress: current "Shipped" is not a stepA literal current must be one of steps, or its 1-based number.
products: use :items or :::product children, not bothPick one source of tiles.

Merge tags and bindings

MessageCause 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.DriverA later segment doesn't exist on that struct.
{{driver}} is main.Driver, not a scalarA 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.TTwo embedded structs promote the same name. Rename one or add JSON tags that differ.
receipt: :order is string, want data.ReceiptA binding must resolve to exactly the module's type.
receipt: missing binding ":order"A required binding is missing.

Families

MessageCause 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 itRemove 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 mastheadRemove issue.
frontmatter: family "buyer" has no legal variantlegal: 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:

Terminal
go run ./cmd/lttr lint 03-password-reset.md
Output
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:

Output
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

Copyright © 2026