MF2 Messages in HEEx

Copy Markdown View Source

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

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:

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.

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:

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:

SourceDerived 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):

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

Identical expressions interpolated twice share a single binding:

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

Translator workflow

Run the standard Gettext extraction tasks:

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:

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

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

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 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:

<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~tt/1
ReturnsString.t()Phoenix.HTML.safe()
Use siteAnywhereHEEx {...} interpolation
MF2 markupStrippedRendered as HEEx via the registry
Bindings from @assignYes (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:

{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.

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

Or with attributes directly:

<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 tagHEEx output
bold, strong<strong>…</strong>
italic, emphasis, em<em>…</em>
code<code>…</code>
linkPhoenix <.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:

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}:

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:

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

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:

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.