Authoring

Modules

Every block component a template can use — its attributes, what it may contain and the rules the compiler enforces.

A template's body is a stack of modules. Each top-level block component is one module, and each run of loose Markdown between components becomes an implicit letter. The layout picks the spacing between two modules from their classes, so a template never sets margins or padding.

Look up a module

A component can be written with its descriptive name or with its alias, the handoff ID (k1, T2, c3…). Both are matched case-insensitively and compile to the same module.

Module (alias)ClassAttributes and bindingsContentRules
statement (k1)Heroeyebrow, tone="orange", size="xl"headings h1–h3, paragraphs, lists, action rowsAt most one heading.
figure (k2)Heroeyebrow, amount (required), usdparagraphs
offer (e3)Heroeyebrowone ## heading, then one terms paragraphThe terms paragraph is required.
image (k3)Bandsrc, alt, height=340, tonenoneMust directly follow a statement. alt is required with src.
map (t4)Bandsrc, alt, pickup, dropoff, height=260noneNeeds alt, or both pickup and dropoff.
letter (b1)Bodynoneparagraphs, ### headings, lists, :link rowsOver 4 paragraphs is a warning.
meta (t3)Bodynone### Label + paragraph pairs1–4 pairs.
quote (e2)Bodyciteone paragraph
notice (b2)Noticetitle, tone="warn"1–2 paragraphs
breakdown (b4)Tabularnoneone GFM tableExactly 2 columns. The header row renders as a body row.
compare (b5)Tabularnoneone GFM table with a headerAt most 3 columns.
tableTabularheadlessone GFM tableAt most 4 columns.
receipt (t1)Tabular:order → data.Receipt (required), total_labelnone
stock (t5)Tabular:items → []data.StockItem (required)none
stories (e1)Editorialnone1–4 :::story tiles
products (e4)Editorial:items → []data.Product, ctaor 1–4 :::product tiles:items or tiles, not both.
code (b3)Tailvalue (required)at most one paragraph
progress (t2)Tailsteps or :steps → []string; current or :currentnone2–5 steps. A literal current must be a step.
actions (c1, c2, c3)Tailnoneone paragraph of only :button and :link
signature (s1)Signoffname (required), role, contactnone
signoff (s2)Signoffclosing, namean optional paragraph (the closing)closing as an attribute or a paragraph, not both.
dividerRulenonenone

Know the shared rules

These apply to every module:

  • Attributes and props are one set of keys. Any attribute can also be given in a props block (see Pass props). Setting a key both ways is statement: "eyebrow" set twice. Bindings are the exception: a :key is read only from the attribute list. A props key ":order" is ignored, so ::receipt with only that prop is receipt: missing binding ":order".
  • Unknown keys are errors. A key the module does not read, an #id or a .class is figure: unknown attribute "tone".
  • Values are typed. Text values can hold merge tags. height must be an integer (image: "height" must be an integer), a flag must be bare, true or false (table: "headless" must be a flag), and an enumerated value must be one of its options (statement: tone must be "orange").
  • tone on image, :::story and :::product is one of warm, sea, sun or map: the coloured ground shown when there is no image (image: tone must be one of warm, sea, sun, map).
  • "none" means none. A module whose content column says none rejects any content: receipt: content is not allowed. A block a module does not take is <module>: <what> is not allowed here, for example figure: h1 is not allowed here or notice: action row is not allowed here.
  • Families can forbid modules. A family's Forbidden list is checked at compile time: module "image" is not allowed in family "security".

The previews below are the real emails of examples/quiet, rendered from the templates in the gallery.

Open with a hero

A hero opens the email: an eyebrow line, then the one thing the email is about.

statement

statement (k1) is the main hero: an eyebrow, a heading, copy, a list and the email's actions.

AttributeTypeNotes
eyebrowtextThe small line above the heading.
tone"orange"The eyebrow in the accent colour.
size"xl"The large heading.
templates/order.md
::statement{eyebrow="Order {{order_id}}"}
# On its way.

{{driver.first_name}} will reach {{destination}} by {{eta}}.

:button[Track delivery]{href="{{track_url}}"} :link[View order]{href="{{order_url}}"}
::

It takes headings of level 1 to 3, but at most one (statement: at most one heading). Its action rows may hold the template's one :button — see Actions and spans. Seven of the nine Quiet examples open with a statement; the previews under receipt and code show two.

figure

figure (k2) leads with an amount: a payout, a refund, a balance.

AttributeTypeNotes
eyebrowtextThe line above the amount.
amounttextRequired (figure: missing "amount").
usdtextA secondary line under the amount, such as an approximate US$ value.
templates/payout.md
::figure{eyebrow="Weekly payout · {{period}}" amount="{{amount}}" usd="≈ {{amount_usd}}"}
It should reach your account ending {{account_last4}} within 48 hours.
::

The content is paragraphs only.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

offer

offer (e3) is a promotional hero: a ## heading, often with the offer code as a code span, and a terms paragraph.

templates/campaign.md
::offer{eyebrow="This week only"}
## 15% off with `HERITAGE15`

On island-made goods until 27 Sept. Minimum spend EC$100.
::

The content must be exactly a ## heading followed by one paragraph. Anything else is offer: expected a ## heading then a terms paragraph, offer: terms paragraph is required or offer: only a ## heading and one terms paragraph are allowed.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Add a band

A band runs edge to edge: an image or a map.

image

image (k3) is a full-width picture, or a coloured ground when there is no src.

AttributeTypeNotes
srctextThe image URL. Without it, the band is the tone ground alone.
alttextRequired when src is set (image: alt is required when src is set).
heightintegerPixels, default 340.
tonewarm, sea, sun, mapThe ground colour.
templates/journal.md
::statement{eyebrow="Makers" tone="orange"}
# The woodworkers of Old Road
::

::image{src="{{cover_url}}" alt="A bench in Old Road" tone="warm"}
::

An image must directly follow a statement: anywhere else it is image must directly follow a statement. At render time, a src that evaluates to text needs an alt that does too.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

map

map (t4) is a static map image, such as a delivery route.

AttributeTypeNotes
srctextThe map image URL. Without it, the band is the map ground alone.
alttextThe image description.
pickup, dropofftextWhen alt is not set, the alt is Map from {pickup} to {dropoff}.
heightintegerPixels, default 260.
templates/job.md
::map{src="{{map_url}}" pickup="{{pickup_area}}" dropoff="{{dropoff_area}}"}
::

Give either alt or both pickup and dropoff (map: alt or pickup and dropoff are required). Build src with the mapbox package and host the image on your own CDN: the Mapbox API URL carries your token.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Write the body

letter

letter (b1) is plain correspondence. You rarely write it: every run of loose Markdown between components is compiled as a letter.

templates/fees.md
Hi {{first_name | there}},

We're lowering commission on services bookings from 1 November.

:link[Read the full policy]{href="https://example.com/policies/fees"}

::letter … :: (or ::b1) does the same explicitly and takes no attributes. A letter holds paragraphs, ### headings, lists whose items are one paragraph each, and action rows of :link only: :button is letter: :button is not allowed; use :link. Other headings are errors (letter: h1 is not allowed here), so put a big heading in a statement. More than four paragraphs is the warning letter: more than 4 paragraphs.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

meta

meta (t3) is a list of labelled details, laid out in two columns that stack on mobile.

templates/order.md
::meta
### Deliver to
{{deliver_to.name}}
{{deliver_to.address}}

### Driver
{{driver.name}} · ★ {{driver.rating}}
::

The content must alternate a ### Label heading and one paragraph (meta: expected "### label" then a paragraph), with 1 to 4 pairs (meta: 1 to 4 pairs, got 5). A line break inside the paragraph is kept.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

quote

quote (e2) is a pull quote with an optional cite line.

templates/journal.md
::quote{cite="Dwayne Liburd · Brimstone Woodcraft"}
"Every board leaves Old Road with the grain facing the sea."
::

The content is exactly one paragraph (quote: expected one paragraph).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Call out a notice

notice

notice (b2) is a boxed note, with an optional bold title. tone="warn" gives it the warning colours.

templates/reset.md
::notice{title="Didn't ask for this?"}
Your password hasn't changed. If you keep getting these, [secure your account]({{security_url}}).
::

It holds one or two paragraphs (notice: 1 or 2 paragraphs, got 3). Links in the text are fine; action rows are not.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Show a table

Tabular modules hold one GFM table or a bound list. Cells are inline Markdown: spans, strong, code spans and links. In a cell, write a merge tag fallback as {{x \| y}} — see Build tables. A table module whose content is anything but one table is breakdown: expected one table.

breakdown

breakdown (b4) is a two-column list of labels and values. The header row renders as an ordinary body row, so the first line of the table is data.

templates/payout.md
::breakdown
| Sales · {{order_count}} orders | {{sales}} |
| --- | ---: |
| Commission · {{commission_rate}} | {{commission}} |
| [Reference]{.small .muted} | [`{{reference}}`]{.small .muted} |
::

The table must have exactly 2 columns (breakdown: table must have 2 columns, has 3).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

compare

compare (b5) shows values before and after a change. The header row is a real header.

templates/fees.md
::compare
| Commission | Now | From 1 Nov |
| --- | ---: | ---: |
| Goods | 6% | 6% |
| Services | 4% | **3.5%** |
::

At most 3 columns (compare: table must have at most 3 columns, has 4). With three columns the middle ("now") column renders smaller and in the secondary colour, and **bold** in a value cell renders as the new value, bold in the action colour (D13).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

table

table is a general table of up to 4 columns (table: table must have at most 4 columns, has 5). The headless flag renders the header row as a body row.

templates/ops.md
::table
| Date | Where | Time |
| --- | --- | ---: |
| **Sat 5 Oct** | Basseterre office | 9:00 am |
| **Sat 12 Oct** | Charlestown ferry terminal | 10:00 am |
::
The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

receipt

receipt (t1) is an order summary built from a data.Receipt in your data: line items with thumbnails, fees and a total.

templates/order.md
::receipt{:order="order"}
::
KeyTypeNotes
:orderbinding → data.ReceiptRequired (receipt: missing binding ":order"). Any other type is receipt: :order is string, want data.Receipt.
total_labeltextThe label of the total row. Without it, Receipt.TotalLabel, then "Total".

data.Receipt holds Lines (Name, Detail, Qty, Amount, Thumb, Tone), Fees (Label, Amount), Total and an optional TotalUSD. A line's sub-line reads Detail · Qty N in HTML, with the quantity shown when it is above 0.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

stock

stock (t5) lists stock levels from a []data.StockItem (Name, SKU, Left). Each row reads "N left", or "Out of stock" at zero.

templates/stock.md
::stock{:items="low_stock"}
::

:items is required (stock: missing binding ":items") and must be exactly []data.StockItem (stock: :items is []string, want []data.StockItem). An empty list is a render error, since a silently empty section would leave the layout's spacing wrong. None of the Quiet examples uses stock.

Show tiles

Editorial modules lay tiles out two to a row. An odd last tile gets an empty filler cell on desktop.

stories

stories (e1) is a row of 1 to 4 story tiles. Each tile is a :::story whose body is one paragraph, the title.

templates/journal.md
::stories
:::story{eyebrow="Stays" href="https://example.com/journal/stays" tone="sea"}
Four guesthouses on the Nevis Peak trail
:::

:::story{eyebrow="Food" image="{{sauce_img}}" href="{{sauce_url}}" cta="Get the recipe"}
What goes into Betty's Hope pepper sauce
:::
::
:::story attributeTypeNotes
hreftextRequired (story: missing "href").
eyebrowtextThe line above the title.
imagetextThe tile image URL. Without it, the tile shows its tone ground.
alttextDefaults to the title's text (D11).
ctatextThe link label, default "Read".
tonewarm, sea, sun, mapThe placeholder ground.

Only :::story components may sit inside (stories: only :::story is allowed here), 1 to 4 of them (stories: 1 to 4 stories, got 5), and each body must be one paragraph (story: body must be one paragraph (the title)).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

products

products (e4) is a grid of 1 to 4 product tiles, either bound to a []data.Product or written out as :::product tiles.

::products{:items="products" cta="Shop"}
::
KeyOnNotes
:items::productsBinding → exactly []data.Product (Name, Price, Href, Image, Tone).
cta::products, :::productThe link label, default "Buy". A tile's own cta wins.
name, price, href:::productRequired (product: missing "price").
image, alt, tone:::productalt defaults to name (D11).

Use :items or tiles, not both (products: use :items or :::product children, not both), and one of them is required (products: missing :items or :::product children). A :::product has no content. An empty bound list is a render error.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

End with a tail

code

code (b3) shows a one-time code in large monospace type, with an optional expiry line.

templates/reset.md
::code{value="{{otp_code}}"}
Requested {{requested_at}} from {{device}} near {{location}}.
::

value is required (code: missing "value"). Spaces in the rendered value are removed, so "482 913" shows as 482913. The content is at most one paragraph (code: at most one paragraph).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

progress

progress (t2) is a step tracker of 2 to 5 steps.

::progress{steps="Confirmed, Packed, On its way, Delivered" current="On its way"}
::
KeyTypeNotes
stepsliteral textA comma-separated list of 2 to 5 steps (progress: 2 to 5 steps, got 6). No merge tags (progress: steps must be literal text).
:stepsbinding → []stringThe steps from your data (progress: :steps is string, want []string). The count is checked at render.
currenttextThe current step: a step's exact name, or its 1-based number (D12).
:currentbindingA string (a step name) or an integer (a step number), checked at render.

Give steps or :steps (progress: missing steps), and at most one of current and :current. Both in one {…} list is the parse error duplicate attribute "current"; a props current together with a :current attribute is progress: "current" set twice plus progress: set current or :current, not both (the same holds for steps and :steps). A literal current is checked against literal steps at compile time: progress: current "Shipped" is not a step. Without a current step, every step renders as not reached.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

actions

actions (c1, c2, c3) is a row of buttons and links on its own, after the other modules.

templates/payout.md
::actions
:button[View payout]{href="{{payout_url}}"} :link[See orders]{href="{{orders_url}}"}
::

The content must be exactly one paragraph made only of :button and :link (actions: expected one paragraph of :button/:link). The three aliases compile to the same module. See Actions and spans for the rules of each action.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Sign off

signature

signature (s1) is a personal sign-off: a name, a role and a contact line.

templates/outreach.md
::signature{name="Marisa Hodge" role="Partnerships Lead" contact="marisa@example.com · +1 869 555 0142"}
::

name is required (signature: missing "name"), and the module takes no content.

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

signoff

signoff (s2) is a team sign-off: a closing line and a name.

templates/payout.md
::signoff{closing="Thanks for selling with us,"}
::
AttributeDefaultNotes
closing"Thanks,"Or write the closing as the module's one paragraph, which may use inline Markdown.
nameThe {brand} team{brand} is Theme.Brand.Name (D7).

Set the closing one way only (signoff: set closing as an attribute or a paragraph, not both). Any content other than one paragraph is signoff: expected one paragraph (the closing).

The email gallery is not available
public/gallery/manifest.json was not found when the site was built. Run `pnpm gallery` in docs-site/ (it needs Go).

Separate modules

divider

divider is a horizontal rule between two modules. It takes no attributes and no content (divider: content is not allowed).

templates/journal.md
::divider
::

A Markdown --- between modules is not a divider: it is the error use ::divider instead of ---.

Next steps

Copyright © 2026