# Template helpers

Postcraft registers a small set of helpers on top of the Handlebars [built-ins](https://handlebarsjs.com/guide/builtin-helpers.html) (`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`](#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):

| Option              | Meaning                                                                                           |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| `title`             | Heading above the chart                                                                           |
| `color`             | Series color (defaults to the template's theme accent)                                            |
| `palette`           | Comma-separated colors for pie slices / segments, e.g. `palette="#6e7ef0,#22c55e"`                |
| `valueLabels`       | `valueLabels=true` prints each value on its bar/slice/point (percentages for `pie` / `segmented`) |
| `xLabel` / `yLabel` | Axis captions (not used for `pie` / `segmented`)                                                  |
| `width`             | Display width in px, **defaults to 100%** of the container                                        |
| `height`            | Chart height in px (sets the aspect; defaults per type)                                           |
| `align`             | `left` / `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}}
```

| Helper | Test                     |
| ------ | ------------------------ |
| `eq`   | equal                    |
| `ne`   | not equal                |
| `gt`   | greater than             |
| `gte`  | greater than or equal to |
| `lt`   | less than                |
| `lte`  | less 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`](#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

| Token  | Output          |     | Token | Output               |
| ------ | --------------- | --- | ----- | -------------------- |
| `yyyy` | 2026            |     | `HH`  | 09 (24-hour, padded) |
| `yy`   | 26              |     | `H`   | 9 (24-hour)          |
| `MMMM` | October         |     | `hh`  | 09 (12-hour, padded) |
| `MMM`  | Oct             |     | `h`   | 9 (12-hour)          |
| `MM`   | 10              |     | `mm`  | 05                   |
| `M`    | 10 (no padding) |     | `m`   | 5 (no padding)       |
| `EEEE` | Thursday        |     | `ss`  | 45                   |
| `EEE`  | Thu             |     | `s`   | 45 (no padding)      |
| `dd`   | 15              |     | `a`   | AM / PM              |
| `d`    | 15 (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](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)) 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):

| Option  | Meaning                                                                |
| ------- | ---------------------------------------------------------------------- |
| `color` | Icon color, baked into the PNG (defaults to near-black ink)            |
| `size`  | Display 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):

| Option    | Meaning                                                                          |
| --------- | -------------------------------------------------------------------------------- |
| `variant` | `black` (default) or `white`. A store publishing one colorway uses it either way |
| `height`  | Lockup 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:**

| Option   | Meaning                                                                                                                                             |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lang`   | `js`, `ts`, `tsx`, `json`, `bash`, `html`, `css`, `python`, `go`, `php`, `ruby`, `java`, `sql`, `yaml`, `handlebars`, or `plain` (default: `plain`) |
| `theme`  | `postcraft-light`, `postcraft-dark` (default), `github-light`, `github-dark`, `nord`, `dracula`, `one-dark-pro`, `catppuccin-mocha`                 |
| `bg`     | Panel background override, e.g. `bg="#0b1021"`; token colors stay the theme's own                                                                   |
| `radius` | Corner radius in px, `0`–`64` (default: `6`); `radius=0` squares the panel                                                                          |
| `src`    | base64url-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&nbsp;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.
