Merge tags and bindings
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.
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)
}
}
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"`isorder_id. Options after the comma (,omitempty) are ignored. - a struct field by its lower-cased name when it has no JSON name.
Phoneisphone,TrackURLwithout a tag would betrackurl. Give fields JSON tags to choose readable names. - a promoted field of an embedded struct, as Go promotes it:
track_urlabove comes from the embeddedTracking. Embedded pointers to structs are followed too. - a map key in a
map[string]V:labels.etareadsLabels["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:
| Error | Cause |
|---|---|
unknown name "otp_cod" | The first segment is not a field of your type or of the recipient. |
unknown field "nope" in main.Driver | A 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.T | Two 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:
- your data type
T, lttr.Recipient(the same type asdata.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:
| Path | Field |
|---|---|
address | Address, the recipient's email address |
first_name | FirstName |
region | Region |
entity_address | EntityAddress |
preferences_url | PreferencesURL |
unsubscribe_url | UnsubscribeURL |
Print only scalars
A merge tag prints one value, so its path must end at a scalar:
- a string kind (
stringor a named string type), - an integer kind (
int,int64,uint8…), - any type implementing
fmt.Stringerwith a value receiver, which includesdata.Moneyanddata.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:
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:
::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:
| Binding | Type |
|---|---|
receipt :order | data.Receipt |
stock :items | []data.StockItem |
products :items | []data.Product |
progress :steps | []string |
progress :current | a 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 inhttps://{{shop}}.example.com/orhttps://a.{{shop}}.example.com/.https://accounts-{{shop}}.example.com/fails too, since the tag is preceded byaccounts-, not//or.. A missing optional value there (an image or mapsrcwith 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:
| Template | Rendered |
|---|---|
{{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}}/x | render 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.
href="{{track_url}}". Writing href="https://example.com/{{path}}" with a path that contains / gives %2F, not a nested path.Use placeholders only in footer copy
{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:
go run ./cmd/lttr tags -t order.out_for_delivery
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
- Actions and spans for the
hrefattributes these URLs go into. - Compile errors for every path error in context.