ESC
Type to search...
S
Soli Docs

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:

  1. a locale value in the session — the person picked it;
  2. a locale cookie;
  3. the best match for the Accept-Language header among the locales you ship — fr-CA is served by a fr you have, and a language you have no file for is skipped rather than negotiated to;
  4. SOLI_DEFAULT_LOCALE, or I18n.set_default_locale(...), defaulting to en.

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: 0 and 1 are both _one — « 0 article », singular.
  • English has _one and _other; 0 takes _other (“0 items”).
  • Russian and Polish use _few and _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.