NAME
Punk::Plugin::I18n - translations and language negotiation
SYNOPSIS
package MyApp;
use Punk;
plugin 'I18n' => { dir => 'i18n', default => 'en' };
get '/' => sub {
my ($c) = @_;
$c->text($c->locale('greeting', name => 'Bob'));
};
1;
DESCRIPTION
One JSON catalogue per locale, negotiated against the request, looked up by key.
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.
Options
dir-
Where the catalogues live. Required.
default-
The locale used when negotiation finds nothing. Required, and it must have a catalogue: it is the answer to every question the other sources could not answer, so an application without one renders empty pages.
param-
The query parameter naming a locale explicitly. Defaults to
lang. -
Where an explicit choice is remembered. Defaults to
punk.lang.
Catalogues are read at boot
Every catalogue is read and parsed once, before the workers fork, and is shared across the pool for the life of the process. Nothing is read or parsed during a request.
A catalogue that will not parse, a missing directory, or a default with no catalogue is an error at boot, in front of whoever deployed it, rather than a missing string at three in the morning.
There is no reload. Changing a translation is a deploy.
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>
<p>{% locale.items.one %}</p>
A path is escaped, which is the safe default and needs no thought. A catalogue entry that carries markup - a link inside a sentence - needs raw:
<p>{% raw locale.tos_link %}</p>
That is safe here for a specific reason: this path takes no substitutions. The rule below is about substituted values reaching markup, and a hash has nowhere to put one. Anything with a value in it belongs in the handler, where $c->locale($key, %values) escapes what it interpolates.
A missing key renders as the key here too, exactly as it does through $c->locale: an omission cannot be visible in your code and invisible on the page.
This needs Template::Stencil 0.10 or newer.
Plurals
Pass count and the category is chosen by the locale's own rule:
"items": {
"one": "{count} produkt",
"few": "{count} produkty",
"many": "{count} produktow",
"other": "{count} produktu"
}
$c->locale('items', count => 5); # 5 produktow
The categories are CLDR's - zero, one, two, few, many and other - and which of them a language uses, at which numbers, is the language's business rather than yours. Polish changes form at 2 and again at 22; Arabic has a form for zero and one for two; Japanese has one form for every number; French says "0 article". A system that branches on $count == 1 is English's grammar under another name, and it is wrong invisibly, because the sentence it produces is grammatical - just not for that number.
other is the category every rule can reach, so a catalogue that uses plurals must carry it. A category the translator has not written yet falls back to it: a partly written plural map is a translation in progress, not an error.
A language whose rule is not known is a boot error when its catalogue uses plural categories - it is never quietly given English's rule. Ordinary strings in such a language are unaffected.
The count is read as it was written: 1 is one in English and "1.0" is other, because "1.0 item" is wrong. That is CLDR's v operand, and it is the only operand that matters for whole numbers.
In a template, pluralise in the handler and pass the string in - the template side takes no substitutions, and {count} is one.
Nested keys
An object nests, and the levels join with a dot:
{ "items": { "one": "1 item", "other": "{count} items" } }
$c->locale('items.one')
$c->locale
$c->locale # the negotiated tag, e.g. 'en-GB'
$c->locale($key) # the translation
$c->locale($key, %values) # interpolated
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: an empty gap hides the omission until a user finds it, while the key is visible in the page and greppable in the logs.
The no-argument form is $c->locale rather than a separate $c->language, deliberately: one name means one concept - the language this request is in - whether you are asking which it is or asking it for a string. The template side of it is spelled the same way.
What it counts
my %s = Punk::Plugin::I18n->stats; # missing, untranslated, warned
missing and untranslated are separate because they are different problems. A key in no catalogue at all is a bug. A key the negotiated locale has not translated yet, which fell back to the default, is not a bug - it is what a partly translated site is, and the number is how an application measures its own coverage.
A missing key also warns once per key, in development only. Once, because twenty renders of the same page producing twenty identical lines is noise, and noise is filtered out. Development only, because a missing key in production is already visible in the page: a line per request would be telling somebody something they can see and cannot act on at that moment. warned counts the warnings, so "it did not warn in production" is a number rather than the absence of one.
An unknown placeholder is left alone, so {nmae} renders as {nmae} rather than vanishing. A missing value renders as nothing. A substituted value is never rescanned for placeholders, so a user named {admin} cannot reach into the catalogue.
How a language is chosen
Explicit beats implicit:
?lang=- an explicit act by the user, on this request.The stored choice, from the cookie - an explicit act on an earlier one.
Accept-Language- what the browser was configured with, which is often not what the user wants and is never something they did on this site.default.
An explicit ?lang= is remembered, or a language switcher works once and appears broken on the next link. A ?lang= naming a locale with no catalogue falls through to the next source rather than failing: it is exactly the parameter people hand-edit.
Negotiation
Accept-Language is negotiated with q-values, and the two things naive matching gets wrong:
en-GB falls back to a catalogue holding en, on subtag boundaries - so en never matches ens. The fallback does not run the other way: a request for en is not served en-GB, because serving pt-BR to a request for pt gives a Portuguese speaker Brazilian spelling with no way to refuse.
q=0 is an exclusion rather than an absence. Accept-Language: en, fr;q=0 means never French, including through *.
SECURITY
A translated string is a template, and a template is an injection site.
The rule is: the catalogue is trusted, the substitutions are not. Values passed to $c->locale are interpolated into a string that may contain markup - a link inside a sentence is the ordinary case - so the catalogue is what is allowed to carry that markup and a substituted value is not.
The consequence is worth stating plainly: translation files are code. They are reviewed as code, deployed as code, and must not be writable by anything a user can reach. "Let the translators edit them in the admin" is a natural next request, and it converts a trusted input into an untrusted one.
SEE ALSO
AUTHOR
LNATION, <thisusedtobeanemail at gmail.com>
LICENSE AND COPYRIGHT
This software is copyright (c) 2026 by LNATION.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.