Skip to content

Template helpers ​

Postcraft registers a small set of helpers on top of the Handlebars built-ins (if, unless, each, with, lookup, log). The helpers below are available in every template render, both in code-mode templates and inside visual-mode block content.

capitalize ​

{{capitalize value}} → uppercases the first character, lowercases the tail.

handlebars
{{capitalize "HELLO"}}
→ Hello
{{capitalize "ada lovelace"}}
→ Ada lovelace

If you want each word capitalized, use titleCase instead.

chart ​

{{chart type="bar" data=… }} → renders a chart as an <img>. The chart is drawn server-side and served as a PNG, so it displays everywhere a normal image does, including email clients (no JavaScript required).

handlebars
{{! bound to context data }}
{{chart
  type="bar"
  data=sales
  labels="month"
  values="revenue"
  title="Monthly revenue"
}}

{{! inline JSON }}
{{chart
  type="line"
  data='[{"label":"Jan","value":1200},{"label":"Feb","value":1800}]'
}}

{{! block-body JSON (handy for longer data) }}
{{#chart type="pie"}}
  { "Organic": 540, "Paid": 210, "Referral": 130 }
{{/chart}}

Types: bar (vertical), hbar (horizontal), line, pie, segmented (one horizontal bar whose colored segments show each category's share of the whole, with a legend).

Data (data=) accepts a bound value, an inline JSON string, or block-body JSON, in any of these shapes:

js
[{ "label": "Jan", "value": 1200 }]   // label/value pairs
{ "Jan": 1200, "Feb": 1800 }          // object map (keys are labels)
[{ "month": "Jan", "revenue": 1200 }] // records: set labels="month" values="revenue"

When the data is records (an array of objects), labels and values name the fields to read.

Options (all optional):

OptionMeaning
titleHeading above the chart
colorSeries color (defaults to the template's theme accent)
paletteComma-separated colors for pie slices / segments, e.g. palette="#6e7ef0,#22c55e"
valueLabelsvalueLabels=true prints each value on its bar/slice/point (percentages for pie / segmented)
xLabel / yLabelAxis captions (not used for pie / segmented)
widthDisplay width in px, defaults to 100% of the container
heightChart height in px (sets the aspect; defaults per type)
alignleft / center / right, defaults to center

The chart background is transparent, so it sits on whatever background your template uses. If the data is missing or can't be parsed, the helper renders a small "Chart data unavailable" placeholder instead of failing the whole template.

eq / ne / gt / gte / lt / lte ​

Comparisons. The built-in {{#if}} only tests whether a value is truthy, so pair it with one of these as a subexpression (in parentheses) to branch on a specific value:

handlebars
{{#if (eq data.request_type "Free 15-minute consultation")}}
  <h1>New consultation request</h1>
{{else}}
  <h1>New first-session request</h1>
{{/if}}

{{#if (gte data.total 50)}}
  <p>Your order ships free.</p>
{{/if}}
HelperTest
eqequal
nenot equal
gtgreater than
gtegreater than or equal to
ltless than
lteless than or equal to

eq and ne are strict: no type coercion, so (eq 1 "1") is false. Watch for this with form posts, which often deliver numbers as strings.

The other four order numbers as numbers, including numeric strings: (gt "10" "9") is true, because a form field carrying "10" still means ten. Two non-numeric strings compare alphabetically, which is what makes ISO dates work:

handlebars
{{#if (lt data.due_date "2026-01-01")}}Overdue{{/if}}

Values that can't be ordered (a missing field, a number against a word) make all four false. So on absent data, neither half of a gt / lte pair renders, rather than both guessing. Use {{#if data.total}} first if you need to handle "no value" explicitly.

To branch across more than two values, switch / case / default reads better than a chain of {{else if (eq …)}}.

and / or / not ​

Combine conditions. All three are truthiness tests that match {{#if}} exactly, so 0, "", and an empty list all count as absent. and and or take any number of arguments.

handlebars
{{#if (and (not data.paid) (gt data.amount_due 0))}}
  <p>Payment overdue.</p>
{{/if}}

{{#if (or data.phone data.email)}}
  <p>We'll be in touch.</p>
{{/if}}

not is not the same as {{#unless}}: unless is a block, so it can't nest inside a condition. Reach for not when a negation needs to sit inside an and / or, as above.

formatDate ​

{{formatDate value "pattern"}} → a date, formatted. JSON carries dates as ISO strings, and no email should show a human 2026-10-15T10:30:00Z.

handlebars
{{formatDate data.appointment}}
→ 15 October 2026
{{formatDate data.appointment "dd/MM/yyyy HH:mm"}}
→ 15/10/2026 10:30
{{formatDate data.appointment "EEEE, d MMM"}}
→ Thursday, 15 Oct

The pattern is optional and defaults to d MMMM yyyy.

Tokens ​

TokenOutputTokenOutput
yyyy2026HH09 (24-hour, padded)
yy26H9 (24-hour)
MMMMOctoberhh09 (12-hour, padded)
MMMOcth9 (12-hour)
MM10mm05
M10 (no padding)m5 (no padding)
EEEEThursdayss45
EEEThus45 (no padding)
dd15aAM / PM
d15 (no padding)

Any other character passes through, so d/M/yyyy and d.M.yyyy both work.

Quote your literal words ​

Letters in the pattern are tokens, so a bare word gets eaten: the a in at becomes AM. Wrap literal text in single quotes:

handlebars
{{formatDate data.appointment "d MMMM 'at' HH:mm"}}
→ 15 October at 10:30

Write '' for a literal apostrophe.

Time zone ​

Dates are formatted in UTC unless you say otherwise. This is deliberate: the editor preview runs in your browser and the real send runs on the server, so a zone-dependent default would make the preview lie. Pass tz (any IANA zone) to shift, and daylight saving is handled for you:

handlebars
{{formatDate data.appointment "HH:mm" tz="Europe/Berlin"}}
→ 12:30

Language ​

Pass locale (any BCP 47 tag) to translate month names, weekday names, and AM/PM. Numbers stay as digits, and a locale never rewrites dd/MM/yyyy.

handlebars
{{formatDate data.appointment "EEEE, d. MMMM yyyy" locale="de-DE"}}
→ Donnerstag, 15. Oktober 2026

When the value isn't a date ​

A missing or empty value renders nothing. A value that can't be parsed renders as-is, rather than disappearing. An appointment email that silently drops its date is worse than one showing a value you can see is wrong in the preview. An unusable tz or locale falls back to the default instead of failing the render.

Accepted inputs are an ISO 8601 string (recommended), a Date, or epoch milliseconds. A seconds-based timestamp (as some webhooks send) will render as 1970, so multiply by 1000 first.

icon ​

{{icon "github"}} → renders an icon as an <img>. Like charts, the glyph is drawn server-side and served as a PNG, so it displays everywhere a normal image does, including email clients, which don't render SVG (inline or data URI). The Social block emits these calls under the hood.

handlebars
{{icon "github"}}
{{icon "linkedin" color="#0a66c2" size=32}}
{{! the name can be a bound value }}
{{icon platform color="#ffffff"}}

Names: twitter, facebook, instagram, linkedin, youtube, github, tiktok, mastodon, bluesky, patreon, onlyfans.

Options (all optional):

OptionMeaning
colorIcon color, baked into the PNG (defaults to near-black ink)
sizeDisplay size in px, 12–256 (defaults to 24; rendered at 2× for retina)

An unknown name or invalid color renders nothing rather than failing the template: an icon is decorative, and a visible error artifact in a sent email would be worse than its absence.

storeBadge ​

{{storeBadge "apple"}} → renders a store badge as an <img>. Drawn server-side and served as a PNG, for the same reason icons are. The Store badges block emits these calls under the hood.

handlebars
<a href="https://apps.apple.com/app/id123456789">{{storeBadge "apple"}}</a>
<a href="{{playStoreUrl}}">{{storeBadge "googleplay" height=48}}</a>
{{! white badges for a dark background }}
<a href="{{macUrl}}">{{storeBadge "mac" variant="white"}}</a>

The helper renders the badge only; wrap it in your own <a> to make it clickable.

Names: apple (App Store), googleplay, mac (Mac App Store), microsoft (Microsoft Store), amazon (Amazon Appstore), steam, itchio.

Options (all optional):

OptionMeaning
variantblack (default) or white. A store publishing one colorway uses it either way
heightLockup height in px, 24–120 (defaults to 40; rendered at 2× for retina)

There is deliberately no color option: the only choice is which published colorway to draw.

codeblock ​

Renders a syntax-highlighted code panel (the Code block emits these calls under the hood). Everything is inline-styled, so the panel survives email clients: lines become <br />, indentation becomes non-breaking spaces, and token colors ride on each <span>. No <pre>, no stylesheet, nothing Outlook's Word renderer strips.

Two forms:

handlebars
{{! block form: hand-authored; the body is normal Handlebars }}
{{#codeblock lang="js" theme="postcraft-dark"}}
  const key = "{{{apiKey}}}";
{{/codeblock}}

{{! src form: what the visual Code block emits; base64url-encoded source }}
{{codeblock src="Y29uc3QgeCA9IDE7" lang="js" theme="postcraft-dark"}}

Both forms interpolate the source, so a snippet can carry runtime data: const ref = "{{bookingRef}}"; renders the recipient's own reference. To show the braces themselves, which is what a snippet about a template language needs, escape them the Handlebars way:

handlebars
{{#codeblock lang="handlebars"}}
  <p>Hi \{{ firstName }}</p>
{{/codeblock}}

The difference between the forms is only where the source lives. In the block form the body sits in the template, so an unbalanced {{ in it breaks the surrounding parse. The src form keeps the source out of that parse, which is why the visual Code block uses it: a pasted snippet that is not valid Handlebars renders verbatim instead of taking the template down.

Options:

OptionMeaning
langjs, ts, tsx, json, bash, html, css, python, go, php, ruby, java, sql, yaml, handlebars, or plain (default: plain)
themepostcraft-light, postcraft-dark (default), github-light, github-dark, nord, dracula, one-dark-pro, catppuccin-mocha
bgPanel background override, e.g. bg="#0b1021"; token colors stay the theme's own
radiusCorner radius in px, 0–64 (default: 6); radius=0 squares the panel
srcbase64url-encoded source; used instead of a block body

The picked theme is absolute: a panel keeps its own background and ink on light and dark rows alike. Unknown lang or theme values fall back to plain / the default theme rather than failing the render, and source longer than 16 KB is truncated.

nl2br ​

{{nl2br value}} → HTML-escapes the input and converts newlines (\n, \r\n, \r) to <br> tags.

handlebars
{{nl2br comment}}

The input is escaped first, so only the literal <br> Postcraft inserts is trusted output. Safe to use with user-submitted text.

pluralize ​

{{pluralize count "noun"}} → emits count + " " + noun (with a default +s plural).

handlebars
{{pluralize 1 "item"}}
→ 1 item
{{pluralize 5 "item"}}
→ 5 items
{{pluralize 2 "child" "children"}}
→ 2 children

Optional third positional argument overrides the default plural for irregular nouns.

sanitizeUrl ​

{{sanitizeUrl url}} → returns the URL if it passes the safe-URL allowlist (http, https, mailto, tel, relative, anchor), otherwise returns #.

handlebars
<a href="{{sanitizeUrl href}}">…</a>

The visual-mode button renderer wraps variable-bound href values in this helper automatically, so a context value like javascript:alert(1) cannot produce a clickable XSS link. Use it explicitly in code-mode templates when you embed user-controlled URLs.

stringify ​

{{stringify value}} → JSON-serializes the value. Optional indent=N pretty-prints with N-space indentation.

handlebars
{{stringify data}}
→ {"a":1,"b":2}
{{stringify data indent=2}}
→ { "a": 1, "b": 2 }

Useful for debug previews and for embedding structured data into <script type="application/json"> blocks.

titleCase ​

{{titleCase value}} → Title-cases each word. Word boundary is "leading word char + non-space run", so apostrophes and hyphens carry through:

handlebars
{{titleCase "hello world"}}
→ Hello World
{{titleCase "don't panic"}}
→ Don't Panic
{{titleCase "well-known"}}
→ Well-known

switch / case / default ​

Cooperating block helpers for clean multi-branch logic.

handlebars
{{#switch type}}
  {{#case "a"}}A{{/case}}
  {{#case "b"}}B{{/case}}
  {{#default}}Other{{/default}}
{{/switch}}

Behavior:

  • The first matching case renders its body; subsequent case blocks and the default block stay quiet.
  • default is optional; if no case matches and there's no default, the switch renders nothing.
  • case and default outside a switch no-op (they do not throw), so a misnested template renders blank rather than failing with a 422.
  • Nested switches each get their own state, so inner cases don't bleed into the outer one.