Internationalization
Build multilingual applications with ease. Soli provides built-in i18n support for translations, number formatting, currency, dates, and pluralization.
Global by Design
The I18n class provides everything you need to localize your application: string translations with interpolation, pluralization rules, locale-aware number and currency formatting, and date localization.
Setting the Locale
Use I18n.locale() to get the current locale and I18n.set_locale() to change it.
# Get the current locale (defaults to "en")
current = I18n.locale()
print(current); # "en"
# Set locale to French
I18n.set_locale("fr")
print(I18n.locale()); # "fr"
# Set locale to German
I18n.set_locale("de");
Basic Translation
Translate strings using I18n.translate(key, locale?, translations?). The function looks up the key in your translations hash.
# Define translations
translations = {
"en.greeting" => "Hello",
"fr.greeting" => "Bonjour",
"de.greeting" => "Hallo",
"es.greeting" => "Hola",
"en.welcome" => "Welcome to our app!",
"fr.welcome" => "Bienvenue dans notre application!"
}
# Translate using current locale
I18n.set_locale("fr")
msg = I18n.translate("greeting", null, translations)
print(msg); # "Bonjour"
# Translate with explicit locale
hello = I18n.translate("greeting", "de", translations)
print(hello); # "Hallo"
# Falls back to key if not found
missing = I18n.translate("unknown_key", null, translations)
print(missing); # "unknown_key"
Pluralization
Handle singular and plural forms with I18n.plural(key, n, locale?, translations?). Use _zero suffix for zero, _one for singular, and _other for plural.
translations = {
"en.item_zero" => "No items",
"en.item_one" => "1 item",
"en.item_other" => "{count} items",
"fr.item_one" => "{count} article",
"fr.item_other" => "{count} articles"
}
I18n.set_locale("en")
# Zero form
none = I18n.plural("item", 0, null, translations)
print(none); # "No items"
# Singular form
single = I18n.plural("item", 1, null, translations)
print(single); # "1 item"
# Plural form
multiple = I18n.plural("item", 5, null, translations)
print(multiple); # "{count} items"
# French plural
fr_plural = I18n.plural("item", 3, "fr", translations)
print(fr_plural); # "{count} articles"
Number Formatting
Format numbers according to locale conventions using I18n.format_number(n, locale?).
value = 1234.56
# English format (dot as decimal separator)
en_num = I18n.format_number(value, "en")
print(en_num); # "1234.56"
# French format (comma as decimal separator)
fr_num = I18n.format_number(value, "fr")
print(fr_num); # "1234,56"
# German format
de_num = I18n.format_number(value, "de")
print(de_num); # "1234,56"
Currency Formatting
Format monetary amounts with I18n.format_currency(amount, currency, locale?). Supports common currency codes.
price = 1999.99
# US Dollars
usd = I18n.format_currency(price, "USD", "en")
print(usd); # "$1,999.99"
# Euros with French locale
eur = I18n.format_currency(price, "EUR", "fr")
print(eur); # "€1.999,99"
# British Pounds
gbp = I18n.format_currency(price, "GBP", "en")
print(gbp); # "£1,999.99"
# Japanese Yen
jpy = I18n.format_currency(2500, "JPY", "en")
print(jpy); # "¥2,500"
Date Formatting
Format dates using I18n.format_date(timestamp, locale?). Different locales use different date formats.
# Unix timestamp for January 15, 2026
timestamp = 1768521600
# US format: MM/DD/YYYY
us_date = I18n.format_date(timestamp, "en")
print(us_date); # "01/15/2026"
# French format: DD/MM/YYYY
fr_date = I18n.format_date(timestamp, "fr")
print(fr_date); # "15/01/2026"
# German format: DD.MM.YYYY
de_date = I18n.format_date(timestamp, "de")
print(de_date); # "15.01.2026"
# ISO format (default for other locales)
iso_date = I18n.format_date(timestamp, "ja")
print(iso_date); # "2026-01-15"
DateTime.format() with Locale
The DateTime.format() method accepts an optional locale parameter to produce localized month and day names. This is useful when you need full control over the format pattern while still getting translated names.
dt = DateTime.parse("2024-03-06T14:30:00Z")
# English (default)
dt.format("%A %d %B %Y") # "Wednesday 06 March 2024"
# French
dt.format("%A %d %B %Y", "fr") # "mercredi 06 mars 2024"
dt.format("%d %b %Y", "fr") # "06 mars 2024"
# Spanish
dt.format("%A %d %B %Y", "es") # "miércoles 06 marzo 2024"
# Supported locales: "en", "fr", "es", "de", "it", "pt"
# Numeric specifiers (%Y, %m, %d, %H, %M, %S) are unaffected by locale
Translation Files
Organize translations in JSON files and load them at startup.
{
"en.app_name": "My Application",
"en.nav.home": "Home",
"en.nav.about": "About Us",
"en.nav.contact": "Contact",
"en.messages.success": "Operation completed successfully!",
"en.messages.error": "An error occurred. Please try again.",
"en.user.greeting": "Hello, {name}!",
"en.items_one": "You have 1 item",
"en.items_other": "You have {count} items"
}
# Load translations from JSON file
en_json = file_read("config/locales/en.json")
en_translations = json_decode(en_json)
fr_json = file_read("config/locales/fr.json")
fr_translations = json_decode(fr_json)
# Merge translations
all_translations = en_translations.merge(fr_translations)
# Use in your application
greeting = I18n.translate("nav.home", "en", all_translations)
print(greeting); # "Home"
Supported Locales
The I18n class provides built-in formatting support for these locales:
| Locale Code | Language | Number Format | Date Format |
|---|---|---|---|
en |
English | 1,234.56 | MM/DD/YYYY |
fr |
French | 1.234,56 | DD/MM/YYYY |
de |
German | 1.234,56 | DD.MM.YYYY |
es |
Spanish | 1.234,56 | YYYY-MM-DD |
it |
Italian | 1.234,56 | YYYY-MM-DD |
Method Reference
I18n.locale()
Returns the current locale string. Defaults to "en" if not set.
I18n.set_locale(locale)
Sets the current locale. Returns the locale string.
I18n.translate(key, locale?, translations?)
Translates a key. Uses current locale if not specified. Falls back to the key itself if no translation found.
I18n.plural(key, n, locale?, translations?)
Returns zero (_zero), singular (_one), or plural (_other)
form based on count.
I18n.format_number(n, locale?)
Formats a number according to locale conventions (decimal separator).
I18n.format_currency(amount, currency, locale?)
Formats a monetary amount with currency symbol. Supports USD, EUR, GBP, JPY.
I18n.format_date(timestamp, locale?)
Formats a Unix timestamp as a date string according to locale conventions.
Which locale a request runs in
A request begins with a locale resolved for it, in this order:
- a
localevalue in the session — the person picked it; - a
localecookie; - the best match for the
Accept-Languageheader among the locales you ship —fr-CAis served by afryou have, and a language you have no file for is skipped rather than negotiated to; SOLI_DEFAULT_LOCALE, orI18n.set_default_locale(...), defaulting toen.
The locale is restored when the request ends, so it never crosses from one visitor to the next on the same worker — it used to, and a controller that never called set_locale rendered in whatever language the previous visitor had asked for. Realtime frames (Live View, EUI) resolve it from the socket's session the same way. set_locale still overrides it for the rest of the request.
Plurals follow CLDR
Which suffix a count selects depends on the language, not only on the number. Soli uses the CLDR categories — _zero, _one, _two, _few, _many, _other — and each language uses the ones it has:
- French has no zero category:
0and1are both_one— « 0 article », singular. - English has
_oneand_other; 0 takes_other(“0 items”). - Russian and Polish use
_fewand_many; Arabic uses all six; Japanese and Chinese use only_other.
Two conveniences on top: a _zero key is still honoured for a count of 0 in any language when you write one, because “No items” is often worth saying differently; and a category your file does not declare falls back to _other in the same locale before any other language is consulted.
Before this, every language got English’s zero/one/other. A French page with a correct fr.yml asked for items_zero, did not find it, and rendered the English string in its place.
Best Practice
You no longer have to. Every request begins with a locale of its own — a
locale value in the session, else a locale cookie, else the best
match for Accept-Language among the locales you ship, else
SOLI_DEFAULT_LOCALE. set_locale is for overriding that inside a
request; it no longer has to be called to avoid inheriting the previous visitor's language.