Skip to main content

guides/mf2-messages-in-heex.md

# MF2 Messages in HEEx

This guide covers writing ICU MessageFormat 2 (MF2) messages in Phoenix templates. Three tools are available, in increasing order of HEEx-specificity:

* `~t` sigil from `Localize.Message.Sigils` — compile-time MF2 translation that returns a `String.t()`. Best when you only need plain text (no markup). Usable anywhere in Elixir code, not just templates.

* `Localize.HTML.t/1` macro — the **recommended** form for HEEx templates. Combines Gettext extraction, MF2 interpolation, and markup rendering in one call: `{t("...")}`. Supports inline markup such as `{#bold}…{/bold}` or `{#link navigate=…}…{/link}`.

* `Localize.HTML.message/1` function component — for cases where the MF2 source is dynamic (loaded from a database, etc.). Accepts a runtime `:msgid` attribute and `:bindings` map.

All three rely on a Gettext backend configured with `Localize.Gettext.Interpolation`.

## Setup

### 1. Configure a Gettext backend with MF2 interpolation

```elixir
defmodule MyApp.Gettext do
  use Gettext.Backend,
    otp_app: :my_app,
    interpolation: Localize.Gettext.Interpolation
end
```

Without `Localize.Gettext.Interpolation`, MF2 placeholders like `{$name}` are returned literally because Gettext's default interpolation only recognises the `%{name}` form.

### 2. Opt the calling module into `~t` and `t/1`

In a Phoenix app, the most common spot is the HTML helpers macro in `MyAppWeb`:

```elixir
defmodule MyAppWeb do
  def html_helpers do
    quote do
      use Phoenix.Component
      import Localize.HTML
      use Localize.Message.Sigils,
        backend: MyApp.Gettext,
        sigils: [domain: "messages"]
    end
  end
end
```

Every LiveView, component, and HTML module that does `use MyAppWeb, :html` now has `~t`, the `t/1` macro, and `<.message>` available.

`use Localize.Message.Sigils` accepts:

* `:backend` — the Gettext backend module. Required.

* `:sigils` — a keyword list of sigil-level defaults:

    * `:domain` — default Gettext domain. The default is `:default`.

    * `:context` — default Gettext message context. The default is `nil`.

## The `~t` sigil — plain-text translation

`~t"…"` rewrites Elixir `#{expr}` interpolations as MF2 `{$name}` placeholders, canonicalises the resulting message, registers it with Gettext for translation lookup, and evaluates the MF2 message at runtime.

```elixir
def render(assigns) do
  ~H"""
  <h1>{~t"Hello, #{@user.name}!"}</h1>
  <p>{~t"You have #{count = length(@items)} item(s)"}</p>
  """
end
```

At compile time, `~t"Hello, #{@user.name}!"` expands to roughly:

```elixir
Gettext.Macros.dpgettext_with_backend(
  MyApp.Gettext,
  "messages",
  nil,
  "Hello, {$user_name}!",
  %{user_name: @user.name}
)
```

The `.po` msgid is the MF2-canonical form with `{$name}` placeholders, so translators can use MF2 features (selectors, formatters, plural categories) per-locale without changing the source code.

### Binding key derivation

Binding names are derived from the interpolated expression:

| Source                  | Derived key       |
| ----------------------- | ----------------- |
| `#{name}`               | `name`            |
| `#{@count}`             | `count`           |
| `#{user.name}`          | `user_name`       |
| `#{String.upcase(x)}`   | `string_upcase`   |
| `#{total = a + b}`      | `total`           |

The explicit `key = expr` form always wins and is the way to disambiguate when two expressions would derive the same key (the macro raises a compile error if you don't):

```elixir
~t"#{a = String.upcase(first)} vs #{b = String.upcase(second)}"
```

Identical expressions interpolated twice share a single binding:

```elixir
~t"#{name} is #{name}"
# => msgid "{$name} is {$name}"
```

### Translator workflow

Run the standard Gettext extraction tasks:

```bash
mix gettext.extract
mix gettext.merge priv/gettext
```

The extracted msgids are valid MF2. A translator localising `"Hello, {$user_name}!"` into French may simply translate the text:

```pot
msgid "Hello, {$user_name}!"
msgstr "Bonjour, {$user_name} !"
```

…or use MF2 features when the target language needs them, for example pluralisation:

```pot
msgid "You have {$count} item(s)"
msgstr ".input {$count :number}\n.match $count\n0 {{Aucun élément}}\n1 {{Un élément}}\n* {{{$count} éléments}}"
```

The MF2 evaluator runs the translated string with the bindings supplied at the call site, so pluralisation rules can change per locale without code changes.

## The `t/1` macro — translations with markup (recommended)

The `Localize.HTML.t/1` macro is the most ergonomic way to write translations in HEEx. It combines compile-time Gettext extraction, MF2 interpolation, and markup rendering in one call:

```heex
<h1>{t("Hello, #{@user.name}!")}</h1>
<p>{t("By signing up you accept our {#link navigate=|/terms|}terms{/link}.")}</p>
<p>{t("Read {#bold}#{@count} item(s){/bold}")}</p>
```

At compile time, the macro:

1. Walks Elixir `#{expr}` interpolations and derives flat MF2 binding names (same rules as `~t`).
2. Rewrites the message as canonical MF2 source with `{$name}` placeholders.
3. Emits a `Gettext.Macros.dpgettext_with_backend/5` call so `mix gettext.extract` picks up the msgid.

At runtime:

1. The Gettext lookup returns the translated MF2 source **without** stripping markup (via an internal sentinel that bypasses the markup-stripping interpolation path).
2. The translated source is walked via `Localize.Message.format_to_safe_list/3`.
3. Each markup node is dispatched to a registered component (defaults documented under `<.message>` below).

### Differences from `~t`

| Feature                 | `~t`              | `t/1`                              |
| ----------------------- | ----------------- | ---------------------------------- |
| Returns                 | `String.t()`      | `Phoenix.HTML.safe()`              |
| Use site                | Anywhere          | HEEx `{...}` interpolation         |
| MF2 markup              | Stripped          | Rendered as HEEx via the registry  |
| Bindings from `@assign` | Yes (compile-time AST walk) | Yes (HEEx rewrites `@x` to `assigns.x` before the macro sees it, and the `assigns` prefix is stripped from the derived binding name) |

Use `~t` when you need a plain string outside of HEEx, e.g. for error messages, page titles assigned to other variables, etc. Use `t/1` everywhere else inside templates.

### Options

`t/2` accepts a keyword-list second argument:

```heex
{t("Hello, #{@name}!", locale: :fr)}
{t("Click {#link navigate=|/x|}here{/link}", components: %{"link" => &my_link/1})}
```

* `:locale` overrides `Localize.get_locale/0` for this render only.

* `:components` is a per-call markup component override (same shape as `<.message>`'s).

## The `<.message>` component — for dynamic msgids

MF2 supports inline markup tags. Examples:

```
Please {#bold}read{/bold} the {#link href=|/terms|}terms{/terms}.
Line one{#br/}line two.
```

`Localize.Message.format/3` strips these tags. `<Localize.HTML.message />` preserves them by walking `Localize.Message.format_to_safe_list/3` and dispatching each markup node to a renderer.

```heex
<Localize.HTML.message
  msgid={~t"Read the #{document = "terms"} before clicking {#bold}Accept{/bold}"}
/>
```

Or with attributes directly:

```heex
<Localize.HTML.message
  msgid="Visit {#link href=|/home|}home{/link}"
/>
```

### Attributes

* `:msgid` — the MF2 message string. Required.

* `:bindings` — a map of variable bindings for MF2 placeholders. The default is `%{}`. When `:msgid` comes from `~t`, leave this empty — `~t` injects the bindings into the gettext call.

* `:locale` — a locale name or language tag. The default is `Localize.get_locale/0`.

* `:components` — a map of `%{markup_name => renderer_fun}` overriding defaults and app config for this render only. The default is `%{}`.

### Default markup renderers

| MF2 tag           | HEEx output         |
| ----------------- | ------------------- |
| `bold`, `strong`  | `<strong>…</strong>` |
| `italic`, `emphasis`, `em` | `<em>…</em>` |
| `code`            | `<code>…</code>`    |
| `link`            | Phoenix `<.link>` — accepts `href`, `navigate`, or `patch` MF2 attributes |
| `br` (standalone) | `<br>`              |

All literal text and binding values are HTML-escaped automatically.

The `link` renderer uses Phoenix.Component's `<.link>`, so translators can
emit same-app navigation by writing `navigate` or `patch` in the MF2 source:

```
{#link navigate=|/dashboard|}dashboard{/link}
{#link patch=|/list?tag=urgent|}urgent items{/link}
{#link href=|https://example.com|}external{/link}
```

The renderer falls back to `href="#"` when none of the three attributes
is present. `<.link>` also rejects unsafe `javascript:` and `data:`
destinations at runtime.

### Adding custom markup tags

Markup-name lookups consult three sources in order:

1. The component's `:components` attribute (per-call override).

2. `config :localize_web, :mf2_markup, components: %{…}` (app-wide).

3. Built-in defaults from `Localize.HTML.Message.default_components/0`.

A renderer is a function of one argument `%{attrs: map, children: safe}` that returns either a `Phoenix.LiveView.Rendered.t()` (the recommended form, produced by `~H`) or a `Phoenix.HTML.safe()` value (`{:safe, iodata}`). The `children` value is already rendered and HTML-escaped — pass it straight through, wrap it, or ignore it, but do not escape it again.

Recommended form, using `~H`:

```elixir
defmodule MyAppWeb.MF2 do
  use Phoenix.Component

  def user(%{attrs: %{"id" => id}, children: children}) do
    assigns = %{id: id, children: children}

    ~H"""
    <span class={"user-#{@id}"}>{@children}</span>
    """
  end
end

config :localize_web, :mf2_markup,
  components: %{"user" => &MyAppWeb.MF2.user/1}
```

Raw form, using `{:safe, iodata}`:

```elixir
fn %{attrs: %{"id" => id}, children: {:safe, child_iodata}} ->
  {:safe,
   [~s(<span class="user-), to_string(id), ~s(">), child_iodata, "</span>"]}
end
```

To start from the defaults and add your own:

```elixir
config :localize_web, :mf2_markup,
  components: Map.merge(
    Localize.HTML.Message.default_components(),
    %{"user" => &MyAppWeb.MF2.user/1}
  )
```

### Unknown tags raise

If a translator writes `{#weird}…{/weird}` and `weird` isn't registered, the component raises `Localize.HTML.Message.UnknownMarkupError` listing the tag and the registered tag names. This is intentional — silent fallbacks hide translator mistakes.

## Choosing between `~t`, `t/1`, and `<.message>`

* **Inside HEEx templates** → use `t/1`. It handles both plain text and inline markup, extracts to `.po`, and renders markup correctly.

* **Outside HEEx** (error messages, dynamic strings, anywhere that needs a `String.t()`) → use `~t`. Note that markup is stripped — `~t` is text-only.

* **MF2 source loaded at runtime** (from a database, user content, etc.) → use `<.message msgid={...} bindings={...} />`. Bypasses Gettext extraction (the msgid isn't a literal) but supports the same markup-rendering pipeline.

## A complete example

```elixir
defmodule MyAppWeb.TermsLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <header>
      <h1>{t("Welcome, #{@user.name}")}</h1>
    </header>

    <section>
      <p>{t("By signing up you accept our {#link navigate=|/terms|}terms{/link} and {#link navigate=|/privacy|}privacy policy{/link}.")}</p>
    </section>

    <p>{t("You have #{count = @notification_count} new notification(s)")}</p>
    """
  end
end
```

After `mix gettext.extract`, the `.pot` file contains:

```pot
msgid "Welcome, {$user_name}"
msgstr ""

msgid "By signing up you accept our {#link href=|/terms|}terms{/link} and {#link href=|/privacy|}privacy policy{/link}."
msgstr ""

msgid "You have {$count} new notification(s)"
msgstr ""
```

The third msgid is a natural fit for MF2 selectors in the translation — number-aware pluralisation is the translator's job, not the developer's.