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.