This guide explains how to use Localize.Message for formatting messages using Unicode MessageFormat 2 (MF2) syntax. MF2 is the successor to the legacy ICU Message Format and provides a clearer, more extensible syntax with explicit declarations, a function registry, pattern matching, and markup support.
Overview
Localize.Message.format/3 formats a message string with variable bindings, producing locale-aware output. Messages can contain plain text, interpolated variables, formatted values (numbers, dates, currencies, units), and plural/gender selection.
iex> Localize.Message.format("Hello, {$name}!", %{"name" => "Alice"})
{:ok, "Hello, Alice!"}
iex> Localize.Message.format("You have {$count :number} items.", %{"count" => 1234})
{:ok, "You have 1,234 items."}Message structure
Every MF2 message is either a simple message or a complex message.
Simple messages
A simple message is plain text with optional placeholders. It cannot start with . or {{.
Hello, world!
Hello, {$name}!
Today is {$date :date length=long}.Simple messages are the most common form. Text is literal; placeholders are enclosed in { }.
Complex messages
A complex message starts with declarations (.input, .local) or a body keyword (.match, {{). The output pattern is always wrapped in {{ }} (a quoted pattern) or defined by .match variants.
.input {$name :string}
{{Hello, {$name}!}}.input {$count :number}
.local $greeting = {|Welcome|}
.match $count
1 {{You have one item, {$greeting}.}}
* {{You have {$count} items, {$greeting}.}}Complex messages are particularly useful when multiple selectors are combined. The structure makes it clear to translators what combinations exist:
.input {$pronoun :string}
.input {$count :number}
.match $pronoun $count
he one {{He has {$count} notification.}}
he * {{He has {$count} notifications.}}
she one {{She has {$count} notification.}}
she * {{She has {$count} notifications.}}
* one {{They have {$count} notification.}}
* * {{They have {$count} notifications.}}Variables
Variables are prefixed with $ and refer to values passed as bindings at format time. Variable names are case sensitive.
iex> Localize.Message.format("Hello, {$name}!", %{"name" => "World"})
{:ok, "Hello, World!"}Bindings can be provided as a map with string keys or as a keyword list with atom keys:
iex> Localize.Message.format("Hello, {$name}!", name: "World")
{:ok, "Hello, World!"}Literals
Quoted literals
Quoted literals are enclosed in | | and can contain any text. Use \\ to escape \ and \| to escape | within quoted literals.
{|Hello, world!|}
{|special chars: \| and \\|}Number literals
Number literals follow the pattern [-] digits [. digits] [e [+-] digits]:
{42}
{3.14}
{1.5e3}Expressions
An expression is enclosed in { } and consists of an optional operand, an optional function annotation, and optional attributes.
{$variable} Variable reference
{$count :number} Variable with function
{|literal text| :string} Literal with function
{:datetime} Function-only (no operand)
{$x :number minimumFractionDigits=2} Function with optionsThe general form is { [operand] [:function [options...]] [@attribute...] }.
Functions
Functions transform or format values. They are invoked with :functionName syntax inside an expression.
:string
String coercion. Converts the operand to its string representation.
iex> Localize.Message.format("{$x :string}", %{"x" => 42})
{:ok, "42"}
iex> Localize.Message.format("{$flag :string}", %{"flag" => true})
{:ok, "true"}:number
Locale-aware number formatting.
{$count :number}
{$price :number minimumFractionDigits=2}
{$total :number minimumFractionDigits=1 maximumFractionDigits=4}
{$plain :number useGrouping=never}| Option | Values | Description |
|---|---|---|
minimumFractionDigits | integer | Minimum decimal places (pads with trailing zeros). |
maximumFractionDigits | integer | Maximum decimal places (rounds beyond this). |
useGrouping | auto, always, min2, never | Controls grouping separators. never suppresses them. |
numberingSystem | latn, arab, deva, etc. | Selects a numbering system. |
select | plural, ordinal, exact | Controls .match key resolution (see Pattern Matching). |
iex> Localize.Message.format("{$n :number minimumFractionDigits=2}", %{"n" => 42})
{:ok, "42.00"}
iex> Localize.Message.format("{$n :number maximumFractionDigits=2}", %{"n" => 3.14159})
{:ok, "3.14"}
iex> Localize.Message.format("{$n :number useGrouping=never}", %{"n" => 12345})
{:ok, "12345"}:integer
Formats a number as an integer (truncates the decimal part).
iex> Localize.Message.format("{$n :integer}", %{"n" => 4.7})
{:ok, "4"}:percent
Formats a number as a percentage. A value of 0.85 formats as 85%.
iex> Localize.Message.format("{$ratio :percent}", %{"ratio" => 0.85})
{:ok, "85%"}:currency
Formats a number as a currency amount.
{$amount :currency currency=USD}
{$amount :currency currency=EUR currencyDisplay=narrowSymbol}
{$amount :currency currency=USD currencySign=accounting}| Option | Values | Description |
|---|---|---|
currency | ISO 4217 code (e.g., USD, EUR) | The currency to format with (required). |
currencyDisplay | symbol, narrowSymbol, code | How to display the currency identifier. |
currencySign | standard, accounting | accounting uses parentheses for negative values. |
:unit
Formats a number with a measurement unit.
{$distance :unit unit=kilometer}
{$weight :unit unit=kilogram unitDisplay=short}
{$temp :unit unit=fahrenheit unitDisplay=narrow}| Option | Values | Description |
|---|---|---|
unit | CLDR unit identifier | The unit to format with (required unless binding is a Localize.Unit). |
unitDisplay | long, short, narrow | How to display the unit name (default: long). |
When the bound value is a Localize.Unit struct, the unit and value are derived automatically:
{$distance :unit}:date
Formats a date. The operand is a Date, NaiveDateTime or DateTime, or a string holding an ISO 8601 date or datetime. Beside the form the specification requires, 2006-01-02T15:04:06, a literal may be any other ISO 8601 writes a whole date in: a day of the year, a week date, a time without its seconds, and either without its separators (2006-002, 2006-W01-1T15:04, 20060102T150406).
{$when :date}
{$when :date fields=month-day}
{|2006-01-02| :date length=long}| Option | Values | Description |
|---|---|---|
fields | weekday, day-weekday, month-day, month-day-weekday, year-month-day, year-month-day-weekday | The fields shown (default: year-month-day). |
length | short, medium, long | The room the date has (default: medium). |
The options choose a TR35 semantic skeleton, and the locale decides the order and the widths: in en-AU, {$d :date fields=month-day} is "14 Jun" and with length=long "14 June". A year, month and day at one length is the locale's standard date format of that length, and at long with a weekday its full date.
:time
Formats a time. The operand is a Time, NaiveDateTime or DateTime, or a string holding an ISO 8601 datetime.
{$when :time}
{$when :time precision=second timeZoneStyle=short}| Option | Values | Description |
|---|---|---|
precision | hour, minute, second | The smallest unit shown (default: minute). |
timeZoneStyle | short, long | Adds the zone, "EST" or "Eastern Standard Time"; without it none is shown. |
:datetime
Formats a date with a time.
{$when :datetime}
{$when :datetime dateFields=month-day dateLength=long timePrecision=hour}| Option | Values | Description |
|---|---|---|
dateFields | the values of fields | The date fields shown (default: year-month-day). |
dateLength | short, medium, long | The room the date has (default: medium). |
timePrecision | hour, minute, second | The smallest unit shown (default: minute). |
timeZoneStyle | short, long | Adds the zone. |
A long or full date joins its time with the locale's at-time pattern, so en gives "January 2, 2006 at 3:04 PM". The options of all three functions must be literals, as TR35 requires: {$d :date length=$length} is an error.
Date and time override options
timeZone, hour12 and calendar apply to all three functions and may be set by a variable. An expression that re-annotates a declared date or time inherits them and no other option, so {$d :time} keeps the timeZone of .local $d = {$x :datetime timeZone=UTC dateLength=long} but not its dateLength.
| Option | Values | Description |
|---|---|---|
timeZone | an IANA zone, UTC, input | Converts the value into the zone, or places a floating time in it; input keeps the value's own zone. |
hour12 | true, false | A 12- or 24-hour clock in the locale's own style. |
calendar | a Unicode calendar identifier such as hebrew | Formats in that calendar. |
A zone other than UTC needs a time zone database such as :tz, and calendar needs the calendrical package; without them the option is an error. A string operand with an offset, such as |2006-01-02T15:04:06-05:00|, keeps it, and one without is a floating time.
Styles from earlier drafts
The style, dateStyle and timeStyle options of earlier MessageFormat 2 drafts, which ICU's implementation still takes, choose the locale's standard formats: short, medium, long or full. So do length=full and a style name given as a precision.
{$when :date style=short}
{$when :datetime dateStyle=long timeStyle=short}Declarations
Declarations appear at the start of a complex message, before the body.
.input
Declares an external variable and optionally applies a function to it:
.input {$count :number}This declares that $count is expected as input and should be formatted using :number. Subsequent references to $count in the message body will use the formatted value.
.local
Binds a new local variable to an expression:
.local $greeting = {|Welcome|}
.local $doubled = {$count :number minimumFractionDigits=2}Local variables are available in the message body and in subsequent declarations.
Pattern matching with .match
The .match statement selects one of several variant patterns based on the runtime value of one or more selector expressions.
Single selector
iex> Localize.Message.format(~S"""
...> .input {$count :number}
...> .match $count
...> 0 {{Your cart is empty.}}
...> 1 {{You have one item in your cart.}}
...> * {{You have {$count} items in your cart.}}
...> """, %{"count" => 3})
{:ok, "You have 3 items in your cart."}Multiple selectors
iex> Localize.Message.format(~S"""
...> .input {$gender :string}
...> .input {$count :integer}
...> .match $gender $count
...> male 1 {{He bought one item.}}
...> female 1 {{She bought one item.}}
...> * 1 {{They bought one item.}}
...> male * {{He bought {$count} items.}}
...> female * {{She bought {$count} items.}}
...> * * {{They bought {$count} items.}}
...> """, %{"gender" => "female", "count" => 3})
{:ok, "She bought 3 items."}Variant keys
Each variant has one key per selector. Keys can be:
Literal keys: match when the selector value equals the literal (e.g.,
0,1,male,female).Catchall
*: matches any value (lowest priority).
Matching rules
All keys in a variant must match their corresponding selector values.
Literal keys are matched by string or numeric equality.
Variants are sorted by specificity: fewer
*keys means more specific.The most specific matching variant is selected.
Plural category matching
The :number and :integer functions support plural category matching via the select option:
select=plural(default): resolves to CLDR cardinal plural categories (zero,one,two,few,many,other). Exact numeric keys are matched first, then category keys.select=ordinal: resolves to CLDR ordinal plural categories.select=exact: matches by literal equality only, no plural category resolution.
The category is that of the number as the function displays it, so formatting options take part in selection: {$n :number minimumFractionDigits=1} displays 1 as "1.0", which selects other in English, and {$n :percent} selects by the percentage shown.
Markup
MF2 supports markup elements for structured output. Markup is typically used to wrap regions of text that should become HTML elements, function components, or other host-format structures at render time.
{#link}click here{/link}
{#img src=|photo.jpg| /}
{#button type=|submit|}Click me{/button}Markup elements accept the same option and attribute syntax as expressions.
format/3 versus format_to_safe_list/3
The standard Localize.Message.format/3 function returns a string and strips markup tags from the output. Open and close tags are removed; their children remain:
iex> Localize.Message.format("Click {#link href=|/home|}here{/link}!")
{:ok, "Click here!"}To preserve markup as structure, use Localize.Message.format_to_safe_list/3. It returns a nested list of {:text, String.t()} and {:markup, name, options, children} tuples that a renderer (HEEX component, EEx template, custom HTML builder, etc.) can turn into real output:
iex> Localize.Message.format_to_safe_list(
...> "Hello {$name}, click {#link href=|/home|}here{/link}!",
...> %{"name" => "Kip"}
...> )
{:ok, [
{:text, "Hello Kip, click "},
{:markup, "link", %{"href" => "/home"}, [{:text, "here"}]},
{:text, "!"}
]}Variable interpolation, plural selection, and all other MF2 features work normally — only the markup tags themselves are preserved as structure instead of being stripped.
Unbalanced markup (an open tag without a close, or a close tag without a matching open) returns {:error, %Localize.FormatError{}}.
Escape sequences
Within pattern text (inside {{ }}):
| Sequence | Produces |
|---|---|
\\ | \ |
\{ | { |
\} | } |
Within quoted literals (inside | |):
| Sequence | Produces |
|---|---|
\\ | \ |
| \| | | |
Gettext integration
Localize.Message integrates with Gettext as a custom interpolation module. When configured, Gettext .po files use MF2 syntax for message formatting with full locale-aware interpolation.
Configure Gettext to use MF2 interpolation:
use Gettext, otp_app: :my_app, interpolation: Localize.Gettext.InterpolationMessages in .po files then use MF2 syntax:
msgid "You have {$count :number} items."
msgstr "Sie haben {$count :number} Artikel."API reference
Localize.Message.format/3
Formats an MF2 message with bindings.
messageis an MF2 message string.bindingsis a map with string keys or a keyword list.optionsis a keyword list.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
:locale | atom, string, or LanguageTag | Localize.get_locale() | Locale for formatting functions. |
:trim | boolean | false | Trim leading/trailing whitespace from the message before parsing. |
Returns:
{:ok, formatted_string}on success.{:error, exception}on failure, where the exception is aLocalize.BindErrorfor unbound variables, aLocalize.FormatErrorfor formatting failures, or aLocalize.ParseErrorfor invalid syntax.
Localize.Message.format!/3
Same as format/3 but returns the string directly or raises on error.
Localize.Message.format_to_iolist/3
Formats an MF2 message into an iolist with binding tracking.
Returns:
{:ok, iolist, bound_variables, unbound_variables}on success.{:error, iolist, bound_variables, unbound_variables}when variables cannot be resolved.
Localize.Message.canonical_message/2
Returns a message in its canonical MF2 form: the source printed back from the parsed message, with a literal left unquoted only when it is an MF2 name. Literals keep all their code points, as TR35 requires, so the canonical form of a message parses back to the same message.
Localize.Message.jaro_distance/3
Calculates the Jaro distance (0.0 to 1.0) between two messages, useful for detecting near-duplicate translations.
Complete examples
Number formatting
iex> Localize.Message.format("{$n :number minimumFractionDigits=2}", %{"n" => 42})
{:ok, "42.00"}Local variable binding
iex> Localize.Message.format(~S"""
...> .input {$first :string}
...> .input {$last :string}
...> .local $greeting = {|Welcome|}
...> {{Dear {$first} {$last}, {$greeting}!}}
...> """, %{"first" => "Jane", "last" => "Doe"})
{:ok, "Dear Jane Doe, Welcome!"}Plural selection
iex> Localize.Message.format(~S"""
...> .input {$count :number}
...> .match $count
...> 0 {{Your cart is empty.}}
...> 1 {{You have one item in your cart.}}
...> * {{You have {$count} items in your cart.}}
...> """, %{"count" => 0})
{:ok, "Your cart is empty."}Gender and plural selection
iex> Localize.Message.format(~S"""
...> .input {$gender :string}
...> .input {$count :integer}
...> .match $gender $count
...> male 1 {{He bought one item.}}
...> female 1 {{She bought one item.}}
...> * 1 {{They bought one item.}}
...> male * {{He bought {$count} items.}}
...> female * {{She bought {$count} items.}}
...> * * {{They bought {$count} items.}}
...> """, %{"gender" => "male", "count" => 5})
{:ok, "He bought 5 items."}Specification compliance
The Localize MF2 implementation targets the Unicode MessageFormat 2.0 specification (part of CLDR Technical Standard #35).
| Area | Status |
|---|---|
| Simple messages | Fully supported |
| Complex messages (declarations + quoted pattern) | Fully supported |
.input and .local declarations | Fully supported |
.match with single and multiple selectors | Fully supported |
Variant matching with literal keys and * catchall | Fully supported |
| Quoted and unquoted literals | Fully supported |
| Number literals (integer, decimal, scientific) | Fully supported |
| Variables with string and atom key lookup | Fully supported |
| Function annotations and options | Fully supported |
Attributes (@name, @name=value) | Parsed; not used in formatting |
| Markup (open, close, self-closing) | Parsed; rendered as empty strings |
| Escape sequences | Fully supported |
| BiDi controls and ideographic space | Fully supported |
Built-in function registry
| Function | Spec Status | Implementation |
|---|---|---|
:string | Default | String coercion via String.Chars |
:number | Default | Locale-aware via Localize.Number |
:integer | Default | Integer format via Localize.Number |
:date | Draft | Semantic skeleton via Localize.Date |
:time | Draft | Semantic skeleton via Localize.Time |
:datetime | Draft | Semantic skeleton via Localize.DateTime |
:percent | Stable | Percent format via Localize.Number |
:currency | Stable | Currency format via Localize.Number |
:unit | Extended | Unit format via Localize.Unit |
:list | Localize | Locale-aware list join via Localize.List |
CLDR 49 promoted :currency and :percent from draft to Stable, with the same implementations as before, so nothing here changed except their standing in the specification.
:list — locale-aware list formatting
:list is a Localize-specific extension that takes a list operand and formats it as a localized conjunction or disjunction by delegating to Localize.List.to_string/2. Each element of the list is itself formatted via Localize.Chars, so a list of dates, numbers, units, currencies, or any other type with a Localize.Chars implementation is rendered locale-aware end-to-end with no extra work from the message author.
iex> Localize.Message.format("{$items :list}", %{"items" => ["apple", "banana", "cherry"]}, locale: :en)
{:ok, "apple, banana, and cherry"}
iex> Localize.Message.format("{$items :list}", %{"items" => [1234, 5678]}, locale: :de)
{:ok, "1.234 und 5.678"}
iex> Localize.Message.format("{$items :list}", %{"items" => [~D[2025-07-10], ~D[2025-08-15]]}, locale: :en)
{:ok, "Jul 10, 2025 and Aug 15, 2025"}The function accepts a style (or type) option that maps to a CLDR list style. Recognised values:
style value | CLDR list style | Use |
|---|---|---|
"and" (default) | :standard | Conjunction with the locale's "and"/"und"/"et" word |
"and-short" | :standard_short | Shorter conjunction (e.g. abbreviated "&") |
"and-narrow" | :standard_narrow | Narrowest conjunction |
"or" | :or | Disjunction with "or"/"oder"/"ou" |
"or-short" | :or_short | Shorter disjunction |
"or-narrow" | :or_narrow | Narrowest disjunction |
"unit" | :unit | Used for unit lists ("3 ft 7 in") |
"unit-short" | :unit_short | Shorter unit-list join |
"unit-narrow" | :unit_narrow | Narrowest unit-list join |
iex> Localize.Message.format(~S({$items :list style=or}), %{"items" => ["red", "green", "blue"]}, locale: :en)
{:ok, "red, green, or blue"}
iex> Localize.Message.format(~S({$items :list style=unit-narrow}), %{"items" => ["3", "ft", "7", "in"]}, locale: :en)
{:ok, "3 ft 7 in"}Embedding :list in a larger message is straightforward:
iex> Localize.Message.format(
...> "You have {$items :list} in your cart.",
...> %{"items" => ["apple", "banana", "cherry"]},
...> locale: :en
...> )
{:ok, "You have apple, banana, and cherry in your cart."}Passing a non-list operand returns a format error rather than crashing.
Custom MF2 functions
Any module that implements the Localize.Message.Function behaviour can be registered as a custom MF2 function. This lets companion packages (like localize_person_names) and end-user code add domain-specific functions without modifying Localize core.
Registration options:
- Per-call — pass a
:functionsmap in the options:
iex> {:ok, name} = Localize.PersonName.new(given_name: "José", surname: "Valim", locale: "pt")
iex> Localize.Message.format(
...> "Author: {$name :personName format=long formality=formal usage=referring}",
...> %{"name" => name},
...> locale: :en,
...> functions: %{"personName" => Localize.PersonName.MF2}
...> )- Application-level — register once in
config/config.exs:
# config/config.exs
config :localize, :mf2_functions, %{
"personName" => Localize.PersonName.MF2,
"money" => MyApp.MoneyFunction
}Per-call functions take precedence over application-level functions, which take precedence over built-in functions. A function name with no built-in implementation and no registry entry is an unknown-function error, per the MF2 specification: format/3 returns {:error, %Localize.FormatError{reason: :unknown_function}}.
Implementing a custom function:
defmodule MyApp.MoneyFunction do
@behaviour Localize.Message.Function
@impl true
def format(%MyApp.Money{amount: amount, currency: currency}, func_opts, options) do
locale = Keyword.get(options, :locale)
Localize.Number.to_string(amount, locale: locale, currency: currency)
end
def format(value, _func_opts, _options) do
{:error, "expected a Money struct, got #{inspect(value)}"}
end
endSee Localize.Message.Function for the full callback specification.
Syntax highlighting
For docs, playgrounds, editor tooling, or error reporting, Localize can render an MF2 message as a classified token stream and format it as HTML (with CSS classes) or ANSI-coloured terminal output. Highlighting runs after parsing, so only valid MF2 input is highlighted — parse errors surface the same {:error, %ParseError{}} tuple as elsewhere.
Token stream
Localize.Message.to_tokens/2 returns a flat list of {class, text} tuples. Concatenating the text fields in order reproduces the canonical MF2 message exactly — this invariant is enforced by tests and means highlighting cannot corrupt the original.
iex> Localize.Message.to_tokens("Hello {$name}!")
{:ok,
[
{:text, "Hello "},
{:punctuation_bracket, "{"},
{:variable, "$name"},
{:punctuation_bracket, "}"},
{:text, "!"}
]}Classes follow the same taxonomy as the tree-sitter highlight captures used by the browser-side mf2_wasm_editor, so one stylesheet styles both renderers. The atoms are: :text, :punctuation_bracket, :variable, :function, :keyword (for .input / .local / .match), :tag (markup tag names), :attribute (@translate), :property (option names), :string (quoted literals), :number, :string_escape, :constant_builtin (the * catchall key).
HTML output
Localize.Message.to_html/2 wraps each token in <span class="mf2-…"> with the text HTML-escaped. Atom underscores are converted to CSS-friendly hyphens on emission:
iex> {:ok, html} = Localize.Message.to_html("Hello {$name}")
iex> html
"<span class=\"mf2-text\">Hello </span><span class=\"mf2-punctuation-bracket\">{</span><span class=\"mf2-variable\">$name</span><span class=\"mf2-punctuation-bracket\">}</span>"Options:
:standalone— whentrue, wraps the whole output in<pre class="mf2-highlight"><code>…</code></pre>. Defaults tofalse(fragment output).:class_prefix— prefix applied to each token class. Defaults to"mf2-". Change this if you need to coexist with another highlighter on the same page.
Supply your own stylesheet targeting .mf2-variable, .mf2-string, etc. — or serve one of the 30 bundled themes described below.
Rendered example
Here is what the HTML output looks like once styled. The inline style attributes below map each class to a colour from Makeup's Monokai theme — a dark palette designed for high contrast on dark backgrounds (which is what ExDoc, Hexdocs, and most developer tools default to):
.input {$count :number}
.match $count
1 {{one message}}
* {{you have {$count} {#bold}messages{/bold}}}The CSS-class version (produced by to_html/2) pairs with this stylesheet — a port of Makeup's Monokai theme onto the tree-sitter capture namespace:
pre.mf2-highlight {
color: #f8f8f2;
padding: 0.75em 1em;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.mf2-highlight code { color: #f8f8f2; }
.mf2-punctuation-bracket, .mf2-operator { color: #f8f8f2; }
.mf2-variable, .mf2-variable-builtin { color: #fd971f; }
.mf2-function { color: #a6e22e; }
.mf2-keyword, .mf2-keyword-import { color: #66d9ef; font-weight: bold; }
.mf2-tag, .mf2-keyword-conditional { color: #f92672; }
.mf2-attribute, .mf2-punctuation-special { color: #a6e22e; }
.mf2-property { color: #a6e22e; }
.mf2-string { color: #e6db74; }
.mf2-number { color: #ae81ff; }
.mf2-string-escape { color: #ae81ff; font-weight: bold; }
.mf2-constant-builtin { color: #f92672; font-weight: bold; }For a light theme, swap the palette to Makeup's default (Pygments) colours — #19177C for .mf2-variable, #0000FF for .mf2-function, bold #008000 for .mf2-keyword / .mf2-tag, #BA2121 for .mf2-string, #666666 for punctuation. Because the class names match the tree-sitter capture taxonomy, any tree-sitter-style theme works directly.
Ready-made stylesheets
30 pre-built themes (Monokai, Tango, Friendly, Native, Default, Xcode, Vim, and more) ship with mf2_wasm_editor under its priv/themes/ directory — see its README for the full list and wiring instructions. Each file targets .mf2-highlight (the standalone wrapper) plus every .mf2-<capture> span, so the same file styles both the server-rendered HTML produced by Localize.Message.to_html(message, standalone: true) and the browser-side editor. Pick the same theme in both places for a consistent look.
ANSI terminal output
Localize.Message.to_ansi/2 renders each token wrapped in IO.ANSI colour codes, suitable for IO.puts/1 in IEx, mix tasks, or error reporting:
iex> {:ok, ansi} = Localize.Message.to_ansi("Hello {$name :string}!")
iex> IO.puts(ansi)The default palette uses 4-bit ANSI colours (legible on both light and dark terminals). Override per-class with the :palette option:
Localize.Message.to_ansi("{$name}", palette: %{variable: [:red, :bright]})Custom formatters
If you need a format not built in (e.g. LaTeX, a rich-text editor model), call to_tokens/2 and render the tuples yourself. The Plain formatter (Localize.Message.Formatter.Plain) is the reference for the round-trip invariant and is used internally to emit the canonical string from a token stream.
mix format plugin
Localize ships a mix format plugin that canonicalises MF2 messages. Enable it in .formatter.exs:
[
plugins: [Localize.Message.Formatter.Plugin],
inputs: [
"{mix,.formatter}.exs",
"{config,lib,test}/**/*.{ex,exs}",
"priv/messages/**/*.mf2"
]
]The plugin handles:
~Msigils in Elixir source.mix formatrewrites each sigil body to its canonical MF2 form — the same form the~Msigil emits at compile time. Excess whitespace inside expressions is normalised, and multi-line complex messages (declarations, matchers) are pretty-printed onto consistent lines.Standalone
.mf2files. Any file matching the plugin's.mf2extension is rewritten in place, always ending with a single trailing newline.
The lowercase ~m sigil is intentionally not supported — ~m permits #{interpolation}, which cannot be round-tripped through the MF2 parser without losing structure. Use ~M for anything you want mix format to touch.
Options
Plugin options live under the :mf2 key:
[
plugins: [Localize.Message.Formatter.Plugin],
mf2: [pretty: true]
]:pretty—true | false | :auto(default:auto).:autokeeps single-line input compact and pretty-prints multi-line input;true/falseforce the behaviour regardless of the input shape.
Parse errors
If a message fails to parse, the plugin does not block mix format. It leaves the message unchanged, emits a warning with the file, line, and column reported by the parser, and continues formatting the rest of the file. Fix the MF2 syntax and rerun mix format.
Idempotency
Formatting is idempotent: running mix format twice produces identical output on the second run, so mix format --check-formatted is a reliable CI check. A property test in the Localize test-suite locks this in against future printer changes.