Skip to content

Internationalization

MVC::Keayl::I18n is a translation and localization backend. It loads locale files into one store, looks up translations by dotted key, interpolates and pluralizes them, localizes dates, times, and numbers, and integrates with controllers, views, and forms.

The backend

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use MVC::Keayl::I18n;

my $i18n = MVC::Keayl::I18n.new(
  default-locale    => 'en',
  available-locales => <en fr ru>,
  use-fallbacks     => True,
  raise-on-missing  => False,
);

$i18n.load-locales('config/locales'.IO);

load-locales reads every .yml, .yaml, and .json file in a directory. Each file's top-level keys are locales, and their values are merged into one store, so en.yml and en/forms.yml combine:

1
2
3
4
5
en:
  greeting: "Hello %{name}"
  apples:
    one: "one apple"
    other: "%{count} apples"

load-file loads a single file and store-translations merges a hash directly:

1
$i18n.store-translations('en', { hello => 'Hello world' });

Translation

translate (aliased t) looks up a dotted key in the current locale:

1
2
$i18n.translate('greeting', name => 'Ada');   # "Hello Ada"
$i18n.t('messages.welcome', name => 'Greg');

Named placeholders written %{name} are replaced by the matching argument. A missing interpolation argument raises X::MVC::Keayl::I18n::MissingInterpolation.

Pluralization

When a count argument is given and the entry is a hash of plural categories, the category is chosen by the locale's rule and %{count} is interpolated:

1
2
3
$i18n.translate('apples', count => 1);   # "one apple"
$i18n.translate('apples', count => 5);   # "5 apples"
$i18n.translate('apples', count => 0);   # "no apples" when a zero key exists

The English rule is one/other. MVC::Keayl::I18n::Pluralization ships rules for languages whose plurals differ, including French (0 and 1 are one), Russian (one/few/many), and Polish. Register another with register-plural-rule:

1
2
3
use MVC::Keayl::I18n::Pluralization;

register-plural-rule('cy', -> $count { ... });   # returns a category name

Missing translations, defaults, and fallback

A missing key returns the placeholder translation missing: <locale>.<key>, or raises X::MVC::Keayl::I18n::MissingTranslation when the backend is built with raise-on-missing => True.

A default is consulted before reporting a key missing. A single default is a literal string. A list is a chain: each entry is tried as a translation key, and the last entry doubles as a literal fallback:

1
2
$i18n.translate('absent', default => 'Fallback text');
$i18n.translate('absent', default => ['other.key', 'Plain text']);

When use-fallbacks is on, a region locale falls back to its base locale before the default locale, so en-CA resolves through en.

Localization

localize (aliased l) formats a Date, DateTime, or number against the locale's format data:

1
2
3
$i18n.localize(Date.new(2020, 2, 5));                  # "2020-02-05"
$i18n.localize(Date.new(2020, 2, 5), format => 'long'); # "February 05, 2020"
$i18n.localize($time, format => 'short');               # "02:30 pm"

Format strings live under date.formats, time.formats, and so on, and use strftime directives. Month and day names come from date.month_names, date.day_names, and their abbreviated variants, and %p reads time.am/time.pm.

Numbers are formatted from the number section of the store:

1
2
3
4
$i18n.number-to-delimited(1234567.5);   # "1,234,567.5"
$i18n.number-to-currency(1234.5);        # "$1,234.50"
$i18n.number-to-percentage(66.666);      # "66.7%"
$i18n.number-to-human-size(1536);        # "1.5 KB"

Per-request locale

locale returns the active locale. set-locale changes the backend default, and with-locale applies a locale for the duration of a block and resets it afterwards:

1
$i18n.with-locale('fr', { $i18n.translate('greeting', name => 'Ada') });

MVC::Keayl::I18n::Locale resolves a request's locale from several sources. resolve-locale tries strategies in order and returns the first acceptable match, falling back to a default:

1
2
3
4
5
6
7
use MVC::Keayl::I18n::Locale;

resolve-locale($request,
  strategies => <param header>,
  available  => <en fr>,
  default    => 'en',
);

The param strategy reads ?locale=fr, header parses Accept-Language by quality, subdomain reads the first host label, and domain reads the host suffix. parse-accept-language and the per-source subs are available on their own.

locale-url-options builds the options that carry a locale into generated URLs, merged through MVC::Keayl::Routing::UrlHelpers' default-url-options:

1
locale-url-options('fr');   # { locale => 'fr' }

Controller and view integration

Configure I18n in config/application.json and the application wires a backend into every controller:

1
2
3
4
5
6
7
8
{
  "i18n": {
    "default-locale": "en",
    "available-locales": ["en", "fr"],
    "load-path": "config/locales",
    "strategies": ["param", "header"]
  }
}

Each request resolves the locale from the configured strategies, sets it for the duration of the action, and resets it afterwards. Controllers gain translate (t), localize (l), current-locale, and default-url-options. A leading dot is a lazy key resolved against the controller and action, so in WidgetsController#show self.t('.title') looks up widgets.show.title:

1
2
3
method show {
  self.render(plain => self.t('.title'));
}

Views expose t, translate, l, and localize as helpers:

1
2
%h1= t('.title')
%p= l(post.published-at, format => 'long')

Model and form translation

The backend provides the ActiveModel-style translation surface:

1
2
3
$i18n.human-attribute-name(User, 'email_address');         # "Email address"
$i18n.human-model-name(User);                              # "Member"
$i18n.translate-error(User, 'name', 'too_short', count => 8);

translate-error walks the message chain from the most specific activerecord.errors.models.<model>.attributes.<attr>.<type> to the generic errors.messages.<type>.

FormBuilder consults the backend for labels, placeholders, and submit text when one is present. The form helpers pass the controller's backend through:

1
2
3
4
= simple_form_for(user) -> $f {
  $f.input('email_address', { placeholder => True })
  $f.submit
}

Labels read helpers.label.<model>.<attr>, placeholders (when placeholder is True) read helpers.placeholder.<model>.<attr>, and submit text reads helpers.submit.<model>.<create|update> with the model name interpolated as %{model}. Each falls back to the humanized attribute or Save without a translation.