Locale-aware formatting for the Lua (Luerl) VM, backed by Localize.
Localize.Lua installs a small localize table into a Luerl VM so that Lua scripts — such as the templates rendered by ash_cms — can format numbers, currencies, dates, times, units, lists and MessageFormat 2 messages with full CLDR locale awareness.
Because Luerl runs entirely on the BEAM, there is no foreign runtime to bridge: each localize.* call from Lua is an ordinary Elixir call into Localize. The exposed surface is a curated allowlist of pure formatting functions — no filesystem, no network — so it is safe to hand to untrusted template authors.
def deps do [ {:localize_lua, "~> 1.0"} ] end
Localize requires OTP 27+'s built-in :json module. On OTP 26, also add the polyfill to your own deps, otherwise Localize raises at application start:
{:json_polyfill, "~> 0.2 or ~> 1.0"}
Install the API into a VM and evaluate a script:
lua = Localize.Lua.install(Lua.new()) {[price], _lua} = Lua.eval!(lua, ~S[return localize.currency(1234.56, "EUR", {locale = "de"})]) price #=> "1.234,56 €"
Or use the one-shot helpers:
Localize.Lua.eval!(~S[return localize.percent(0.56)]) #=> ["56%"]
Once installed, a template reads as prose:
local price = localize.currency(item.price, store.currency, {locale = user.locale}) local shipped = localize.date(order.shipped_on, {locale = user.locale, format = "long"}) local summary = localize.message( ".input {$count :integer}\n.match $count\n one {{{$count} item}}\n * {{{$count} items}}", {count = #order.items} )
"The price is the item price as currency in the user's locale. Shipped is the ship date in long form. The summary pluralises the item count with CLDR rules —
1 item,5 items— in whatever language the user reads."
| Lua call | Result (en) |
|---|---|
localize.number(1234.5) |
1,234.5 |
localize.number(1234.56, {currency = "USD"}) |
$1,234.56 |
localize.currency(1234.56, "EUR", {locale = "de"}) |
1.234,56 € |
localize.percent(0.56) |
56% |
localize.date("2025-07-10", {format = "long"}) |
July 10, 2025 |
localize.time("14:30:00") |
2:30:00 PM |
localize.datetime("2025-07-10T14:30:00Z") |
Jul 10, 2025, ... |
localize.relative(-3, "day") |
3 days ago |
localize.unit(42, "kilometer", {format = "short"}) |
42 km |
localize.list({"a", "b", "c"}) |
a, b, and c |
localize.message(mf2, {count = 3}) |
(MF2 with CLDR plurals) |
localize.territory_name("AU") |
Australia |
localize.language_name("de") |
German |
Every function accepts an optional trailing options table ({locale = ..., format = ...}) forwarded to the corresponding Localize function.
-
Using Localize.Lua in Lua — driving the
localizeAPI from Lua: how values cross the Elixir↔Lua boundary, options tables, MessageFormat 2 pluralisation, and the current limitations. -
Using Localize.Lua with AshLua — installing
localizeonto an AshLua VM so a script formats data withlocalize.*right alongside its Ash calls. -
Using Localize.Lua in Ash CMS — wiring
localizeinto the Ash CMS render path, and how it complements the CMS translation helpers.
-
Never raises on the render path. Every binding returns a string and falls back to a safe rendering of its input on any error — an unknown locale, an unparseable date, a malformed table — so a broken template degrades gracefully instead of crashing the host.
-
No atom-table exhaustion. Option values sourced from a Lua script are never passed to
String.to_atom/1; atom-valued options are resolved through a static compile-time allowlist.
Only the en locale ships with Localize. Install the locales your application serves once at build time:
mix localize.download_locales de fr ja
A call for a locale that has not been installed formats with the root locale rather than raising.
The standard gates:
mix format --check-formatted mix compile --warnings-as-errors mix test # `en` only — always green offline mix test --only locales # cross-locale, downloads CLDR data mix credo --strict mix dialyzer mix docs
Apache-2.0. See LICENSE.md.