I18n
Punk::Plugin::I18n is one JSON catalogue per locale, negotiated against the request, looked up by key:
plugin 'I18n' => { dir => 'i18n', default => 'en' };
get '/' => sub {
my ($c) = @_;
$c->text($c->locale('greeting', name => 'Bob'));
};
i18n/en.json { "greeting": "Hello, {name}" }
i18n/en-GB.json { "greeting": "Hello, {name}" }
i18n/fr.json { "greeting": "Bonjour, {name}" }
Placeholders are {name} rather than %s, because positional formats
cannot be reordered and reordering is the whole reason a sentence needs
translating.
dir and default are required, and the default must have a
catalogue - it is the answer to every question the other sources could
not answer. Every catalogue is read and parsed once at boot, before the
workers fork, and shared across the pool; nothing is parsed during a
request, and a catalogue that will not parse is an error at boot rather
than a missing string at three in the morning. There is no reload -
changing a translation is a deploy.
How a language is chosen
An explicit ?lang= parameter (configurable as param), then the
remembered choice in the punk.lang cookie (configurable as cookie),
then Accept-Language, then the default. Negotiation gets the two
things naive matching gets wrong right: en-GB falls back to a
catalogue holding en on subtag boundaries (so en never matches
ens), and the fallback does not run the other way - a request for
pt is not served pt-BR, which would hand a Portuguese speaker
Brazilian spelling with no way to refuse. q=0 is an exclusion, not an
absence.
$c->locale
$c->locale # the negotiated tag, e.g. 'en-GB'
$c->locale($key) # the translation
$c->locale($key, %values) # interpolated
$c->locale('items.one') # nested keys join with a dot
A key missing from the negotiated catalogue falls back to the default catalogue, which is what a partly translated site is. A key missing from both returns the key - not the empty string, because an empty gap hides the omission until a user finds it, while the key is visible in the page and greppable in the logs. In development a missing key also warns, once per key.
In templates
The negotiated catalogue is on the render data as locale, so a
template reads it as an ordinary dotted path with nothing passed by the
handler:
<h1>{% locale.welcome %}</h1>
A path is escaped, the safe default. A catalogue entry that carries
markup - a link inside a sentence - needs raw, and that is safe here
for a specific reason: the template path takes no substitutions, so
there is nowhere for an untrusted value to reach the markup. Anything
with a value in it belongs in the handler, where
$c->locale($key, %values) escapes what it interpolates. This side
needs Template::Stencil
0.10 or newer.
Plurals
Pass count and the category is chosen by the locale's own CLDR rule:
"items": {
"one": "{count} produkt",
"few": "{count} produkty",
"many": "{count} produktow",
"other": "{count} produktu"
}
$c->locale('items', count => 5); # 5 produktow
Which categories a language uses, at which numbers, is the language's
business: Polish changes form at 2 and again at 22, Arabic has a form
for zero, Japanese has one form for everything. A system that branches
on $count == 1 is English's grammar under another name, and it is
wrong invisibly - the sentence it produces is grammatical, just not for
that number. other is the category every rule can reach, so a plural
map must carry it and a category not yet translated falls back to it. A
language whose rule is not known is a boot error when its catalogue
uses plurals - never quietly given English's rule.
The trust rule
A translated string is a template, and a template is an injection site.
The rule: the catalogue is trusted, the substitutions are not. The
catalogue may carry markup; a substituted value is escaped, an unknown
placeholder is left visible ({nmae} renders as {nmae}), and a
substituted value is never rescanned - a user named {admin} cannot
reach into the catalogue.
Punk::Plugin::I18n->stats reports missing (in no catalogue: a bug),
untranslated (fell back to the default: coverage, not a bug) and
warned, so a partly translated site measures itself.