Authoring

Merge tags and bindings

How a template reaches into your Go data — path rules, scope, scalars, fallbacks, bindings and URL escaping.

A template is compiled against one Go type, its T. Every {{path}} merge tag and every :key="path" binding is resolved against that type when you call lttr.Compile, so a typo is a compile error with a line and column, not a blank in a sent email. Rendering then follows precompiled accessors and never parses a path.

main.go
package main

import (
    "fmt"
    "net/mail"

    "github.com/sulv-io/lttr"
    "github.com/sulv-io/lttr/data"
)

type Driver struct {
    FirstName string `json:"first_name"`
    Phone     string // no json tag: matched as "phone"
}

type Tracking struct {
    TrackURL string `json:"track_url"`
}

type OrderShipped struct {
    Tracking                   // embedded: {{track_url}} is promoted
    OrderID  string            `json:"order_id"`
    Driver   *Driver           `json:"driver"` // pointer: nil means "no value"
    Total    data.Money        `json:"total"`
    Order    data.Receipt      `json:"order"`
    Labels   map[string]string `json:"labels"`
}

var families = lttr.NewRegistry(lttr.Family{
    Name:   "orders",
    From:   mail.Address{Name: "Acme", Address: "orders@acme.example"},
    Stream: lttr.Transactional,
    Header: lttr.H1,
    Footer: lttr.F2,
})

const src = `---
template: order.shipped
family: orders
subject: "Order {{order_id}} is on its way"
preheader: "{{labels.eta | Arriving soon}}."
---

::statement{eyebrow="Order {{order_id}}"}
# On its way.

Hi {{first_name | there}}, {{driver.first_name | your driver}} is bringing it.
Call them on {{driver.phone | the number in the app}}.

:button[Track delivery]{href="{{track_url}}"}
::

::receipt{:order="order"}
::

Order total: {{total}}.
`

func main() {
    t := lttr.MustCompile[OrderShipped](families, "order.shipped", []byte(src))
    for _, tag := range t.Meta().Tags {
        kind := "tag"
        if tag.Binding {
            kind = "binding"
        }
        fmt.Printf("%-18s %-13s %-8s %s\n", tag.Path, tag.Type, kind, tag.Pos)
    }
}
Output
order_id           string        tag      4:17
labels.eta         string        tag      5:13
order_id           string        tag      8:28
first_name         string        tag      11:4
driver.first_name  string        tag      11:28
driver.phone       string        tag      12:14
track_url          string        tag      14:31
order              data.Receipt  binding  17:1
total              data.Money    tag      20:14

Template.Meta().Tags lists every tag and binding with the Go type it resolved to. first_name is not a field of OrderShipped, so it resolved to the recipient's first name — see Search data, then the recipient.

Name a path

A path is one or more segments joined by dots. Each segment is lower case: [a-z_][a-z0-9_]*. A segment selects:

  • a struct field by its JSON name. A field tagged `json:"order_id"` is order_id. Options after the comma (,omitempty) are ignored.
  • a struct field by its lower-cased name when it has no JSON name. Phone is phone, TrackURL without a tag would be trackurl. Give fields JSON tags to choose readable names.
  • a promoted field of an embedded struct, as Go promotes it: track_url above comes from the embedded Tracking. Embedded pointers to structs are followed too.
  • a map key in a map[string]V: labels.eta reads Labels["eta"].

Pointers are followed at every step. Unexported fields and fields tagged `json:"-"` never match.

A path that doesn't resolve is a compile error at the tag:

ErrorCause
unknown name "otp_cod"The first segment is not a field of your type or of the recipient.
unknown field "nope" in main.DriverA later segment is not a field of that struct.
cannot descend into data.Money at "total"A path continues past a scalar ({{total.minor}}), a slice or a map with non-string keys.
ambiguous field "name" in main.TTwo embedded structs promote a field with the same name at the same depth. Go rejects the same selector; unlike encoding/json, a JSON tag on one of them does not break the tie.
invalid path "Order"A binding path that is not lower-case segments. (A tag with such a path is not a tag at all, just a malformed-tag warning.)

Search data, then the recipient

The first segment of a path is looked up in two places, in order:

  1. your data type T,
  2. lttr.Recipient (the same type as data.Recipient).

The first match wins. So {{first_name}} is the recipient's first name unless T has a first_name field of its own. The recipient's fields are:

PathField
addressAddress, the recipient's email address
first_nameFirstName
regionRegion
entity_addressEntityAddress
preferences_urlPreferencesURL
unsubscribe_urlUnsubscribeURL

A merge tag prints one value, so its path must end at a scalar:

  • a string kind (string or a named string type),
  • an integer kind (int, int64, uint8…),
  • any type implementing fmt.Stringer with a value receiver, which includes data.Money and data.Exact.

Floats, bools, structs that aren't Stringers, slices and maps are not scalars. A tag that ends at one is a compile error naming the type: {{driver}} is main.Driver, not a scalar, {{driver.rating}} is float64, not a scalar. Format such values in Go (a string field, or a Stringer type) before they reach the template.

A value is formatted with its String() method, as the string itself, or as a decimal integer.

Give a fallback

At render time, a tag has no value when (D8):

  • a nil pointer or a missing map key is on its path, or
  • the value formats to the empty string.

Then its fallback is used:

templates/order.md
Hi {{first_name | there}},
Your driver is {{driver.first_name | "on the way"}}.

A tag with no value and no fallback is a render error, with the tag's position: order.shipped:4:17: no value for {{order_id}}. A few places are optional instead, and a missing value there renders as nothing: preheader; the src and alt of an image or map; the image and alt of a :::story or :::product; and the F2 about value (family copy or footer.about), where an empty result drops the "service email about …" line.

A fallback is literal text. It can't hold another merge tag. Inside a GFM table cell, write the separator as \|: {{first_name \| there}} (see Build tables).

Bind structured values

Some modules need more than a line of text: a whole receipt, a list of products. They take a binding, an attribute whose name starts with : and whose value is a path:

templates/order.md
::receipt{:order="order"}
::

::products{:items="products"}
::

A binding resolves with the same path rules and the same scope as a merge tag, but it must resolve to exactly the type its module needs:

BindingType
receipt :orderdata.Receipt
stock :items[]data.StockItem
products :items[]data.Product
progress :steps[]string
progress :currenta string or integer kind, checked at render

A pointer on the path is followed, so a *data.Receipt field binds too. Anything else is a compile error naming both types: receipt: :order is string, want data.Receipt. A named slice type such as type Items []data.Product is a different type and does not bind.

Know how URLs are escaped

Merge tags in an href, a link destination or an image src are escaped as URL parts at render time (D34). The rule is about where the value sits:

  • At the very start, a value is inserted as is. href="{{track_url}}" passes a whole URL from your data through unchanged, so it can carry its own path and query.
  • After some literal text, in the path, query or fragment, a value is escaped. Before a ? it is escaped as a path segment; after a ? it is escaped as a query value, with spaces as %20.
  • After some literal text, in the host (the URL so far has // and no /, ? or # after it), a value is not escaped, but it must be a whole DNS label of the host: a single label (ASCII letters, digits and -, not at either end), immediately preceded by literal // or ., and immediately followed by literal text starting with ., as in https://{{shop}}.example.com/ or https://a.{{shop}}.example.com/. https://accounts-{{shop}}.example.com/ fails too, since the tag is preceded by accounts-, not // or .. A missing optional value there (an image or map src with no fallback) is the same error, not dropped. Anything else is a render error at the tag: merge value {{shop}} cannot form part of a url host.
  • A fallback follows the same rule as the value it replaces.

With q = a b&c=d/e?f#g@h, email = kim@example.com and shop = shop1:

TemplateRendered
{{q}}a b&c=d/e?f#g@h, as is, then checked as a URL
https://x.example/p/{{q}}https://x.example/p/a%20b&c=d%2Fe%3Ff%23g@h
https://x.example/search?q={{q}}https://x.example/search?q=a%20b%26c%3Dd%2Fe%3Ff%23g%40h
mailto:{{email}}mailto:kim@example.com
https://{{shop}}.example.com/https://shop1.example.com/
https://{{q}}.example.com/render error: q is not a single DNS label
https://accounts-{{shop}}.example.com/render error: shop isn't preceded by // or .
https://{{shop}}/xrender error: no literal . follows the tag

A tag that makes up the whole host, such as https://{{host}} or https://{{host}}/x, always fails, whatever its value. Pass the whole URL as the first part instead: href="{{shop_url}}".

A value can never add a query parameter, change the path or fragment, or move the host. The result is then checked like every URL: an invalid URL or a disallowed scheme is a render error.

If your data holds a complete URL, put it first: href="{{track_url}}". Writing href="https://example.com/{{path}}" with a path that contains / gives %2F, not a nested path.

{name} with single braces is a placeholder, not a merge tag. Placeholders exist in family copy and in the footer.* frontmatter overrides, which appear in the footer, and are filled per message from lttr.Vars: {site}, {brand}, {domain}, {region}, {address}, {about}, {prefs}, {unsubscribe}, {issue} (D5). In the body, subject or preheader, {site} is plain text.

Family copy cannot use merge tags (merge tags are not allowed in copy); footer.* overrides can use both. See Frontmatter and Families and streams.

List a template's tags

Template.Meta().Tags is available on every compiled template, as in the program above. For the Quiet examples, the CLI (run from a checkout of lttr) prints the same list, sorted by position, as path, Go type, tag or binding, and line:col separated by tabs:

Terminal
go run ./cmd/lttr tags -t order.out_for_delivery
Output
destination string  tag 5:25
eta string  tag 5:44
order_id    string  tag 10:28
driver.first_name   string  tag 13:1
picked_up_at    string  tag 13:47
destination string  tag 13:79
eta string  tag 13:98
track_url   string  tag 15:31
order   data.Receipt    binding 21:1
deliver_to.name string  tag 26:1
deliver_to.address  string  tag 27:1
driver.name string  tag 30:1
driver.rating   string  tag 30:24
driver.vehicle  string  tag 31:1

lttr tags knows only the data types of examples/quiet; for your own templates, read Meta().Tags. See CLI.

Next steps

Copyright © 2026