NAME
App::Actions::I18N - message catalogue and translator for App::Actions
SYNOPSIS
Translate one message:
use App::Actions::I18N;
my $i18n = App::Actions::I18N->new(language => 'fr');
binmode STDOUT, ':encoding(UTF-8)';
print $i18n->message('workflow.empty', { file => 'ci.yml' }), "\n";
Choose the language from a country:
my $i18n = App::Actions::I18N->new(country => 'DE'); # German
Use the language the user asked for (ACTION_RUN_LANG):
my $text = App::Actions::I18N->shared->message('logs.removed', { count => 3, size => '2 KB' });
Throw a translated error from your own class:
package My::Loader;
use App::Actions::I18N qw(i18n throw);
sub load {
my ($self, $file) = @_;
$self->throw('workflow.file_missing', { file => $file }) unless -f $file;
return $self;
}
Add a message of your own, with plural forms:
App::Actions::I18N->add_messages(en => {
'demo.files' => { plural => { one => '%(count)d file', other => '%(count)d files' } },
});
print App::Actions::I18N->new->message('demo.files', { count => 2 }); # 2 files
DESCRIPTION
This module translates the messages that App::Actions shows to people.
Every message has a key, a dotted name such as workflow.no_jobs. For each language there is a catalogue: a table from keys to texts. To show a message, you give the key and the values to fill in, and you get the text in the chosen language. The key never changes, so programs can recognise an error by its key even when the text is translated (see App::Actions::Error).
Placeholders. A text marks the places for values with a name in brackets, followed by a sprintf conversion: %(file)s for a string, %(count)d for a whole number. Because the values have names, a translation can put them in a different order. Write %% for a percent sign.
Plurals and genders. An entry can have different texts for different numbers ("1 file", "2 files") or genders. The count value chooses the plural form, using the rules of the language (for example, French uses the singular for 0 and 1). The gender value (female, male or other) chooses the gendered form.
Languages. There are catalogues for English (en), French (fr), German (de) and Chinese (zh). If a language does not have a key, the English text is used. If English does not have it either, the key itself is shown, with a warning. So a missing translation makes a message less helpful, but never stops the program.
Choosing the language. The first of these that is set wins:
- 1. the
languageargument to "new"; - 2. the
countryargument to "new" (a two-letter country code such asGBorcn; upper or lower case); - 3.
$App::Actions::I18N::config{language}; - 4. the
ACTION_RUN_LANGenvironment variable; - 5. English.
The system's own locale (LANG, LC_ALL) is ignored on purpose: see "LIMITATIONS".
METHODS
new
Creates a translator.
ARGUMENTS
language- optional; a language code such asfrorde_DE. Only the part before_or-is used.country- optional; an ISO 3166-1 alpha-2 country code. Ignored whenlanguageis given.
RETURNS
An App::Actions::I18N object.
SIDE EFFECTS
Warns (in English, since no other catalogue can be trusted at that point) when the requested language has no catalogue.
EXAMPLE
my $english = App::Actions::I18N->new;
my $german = App::Actions::I18N->new(country => 'de');
my $french = App::Actions::I18N->new(language => 'fr_FR.UTF-8');
print $german->language, "\n"; # de
API SPECIFICATION
INPUT
{
language => { type => 'string', optional => 1, matches => qr/\A[A-Za-z]{2,3}(?:[_.-].*)?\z/ },
country => { type => 'string', optional => 1, matches => qr/\A[A-Za-z]{2}\z/ },
}
OUTPUT
{ type => 'object', isa => 'App::Actions::I18N' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Language 'X' has no | No catalogue exists for the | Use en, fr, de or zh, or add a |
| catalogue; using English | requested language | catalogue with add_messages |
| Invalid arguments to ... | An argument failed validation | Pass the documented types |
+--------------------------+-------------------------------+--------------------------------+
PSEUDOCODE
validate the arguments
work out the wanted language from the arguments, config and environment
reduce it to its base code (de_DE.UTF-8 -> de)
if there is no catalogue for it, warn and use English
return a blessed object holding the language
shared
Returns the shared translator for the language the configuration and the environment choose.
ARGUMENTS
None. May be called as a class method or a function.
RETURNS
An App::Actions::I18N object.
SIDE EFFECTS
Creates and caches the shared instance; replaces it when ACTION_RUN_LANG or $config{language} has changed since it was made.
EXAMPLE
local $ENV{ACTION_RUN_LANG} = 'de';
print App::Actions::I18N->shared->language, "\n"; # de
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::Actions::I18N' }
MESSAGES
None of its own; see "new".
language
Returns the code of the language the translator writes, such as en.
EXAMPLE
print App::Actions::I18N->new(country => 'CN')->language; # zh
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', matches => qr/\A[a-z]{2,3}\z/ }
MESSAGES
None.
languages
Returns an array reference of the language codes that have a catalogue, sorted.
EXAMPLE
print join(', ', @{ App::Actions::I18N->languages }), "\n"; # de, en, fr, zh
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref', min => 1 }
MESSAGES
None.
catalogue_keys
Lists the keys a language's own catalogue defines, without the English fallback, so a translator or a test can see what is missing.
ARGUMENTS
language- a catalogue language code.
RETURNS
A sorted array reference of keys; empty for a language with no catalogue.
SIDE EFFECTS
None.
EXAMPLE
my %french = map { $_ => 1 } @{ App::Actions::I18N->catalogue_keys('fr') };
my @untranslated = grep { !$french{$_} } @{ App::Actions::I18N->catalogue_keys('en') };
API SPECIFICATION
INPUT
{ language => { type => 'string', matches => qr/\A[a-z]{2,3}\z/ } }
OUTPUT
{ type => 'arrayref' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to ... | The code is not 2-3 lower-case| Pass a code such as fr |
| | letters | |
+--------------------------+-------------------------------+--------------------------------+
language_for_country
Maps an ISO 3166-1 alpha-2 country code to the language of its catalogue.
ARGUMENTS
country- a two-letter country code, in any case.
RETURNS
The language code, or undef when the country has no mapped catalogue.
EXAMPLE
App::Actions::I18N->language_for_country('gb'); # en
App::Actions::I18N->language_for_country('FR'); # fr
App::Actions::I18N->language_for_country('JP'); # undef
API SPECIFICATION
INPUT
{ country => { type => 'string', matches => qr/\A[A-Za-z]{2}\z/ } }
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to ... | The code is not two letters | Pass an ISO 3166-1 alpha-2 code|
+--------------------------+-------------------------------+--------------------------------+
message
Renders one message in the translator's language.
ARGUMENTS
$key- the catalogue key, such asworkflow.no_jobs.\%args- optional; the values of the placeholders.countchooses the plural form andgender(female,maleorother) the gendered form, where the entry has them.
RETURNS
The rendered message as a character string. Placeholders without a value render as an empty string (or 0 for a numeric conversion), never as a warning.
SIDE EFFECTS
Warns when the key is in no catalogue, and then returns the key itself.
EXAMPLE
my $i18n = App::Actions::I18N->new(language => 'fr');
print $i18n->message('logs.removed', { count => 0, size => '0 B' }), "\n";
# the French singular form ("journal", not "journaux"): French counts 0 as one
API SPECIFICATION
INPUT
{
key => { type => 'string', min => 1 },
args => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| No message is defined | The key is in no catalogue | Add the key to the English |
| for 'KEY' | | catalogue |
+--------------------------+-------------------------------+--------------------------------+
PSEUDOCODE
validate the key and the arguments
look the key up in the language's catalogue, then in English
if it is in neither, warn and return the key
while the entry is a hash:
if it has a gender branch, take the one args{gender} names (else other)
if it has a plural branch, take the one the language's rule gives args{count}
replace every %(name)fmt with sprintf(fmt, args{name})
return the text
add_messages
Adds entries to, or replaces entries in, a language's catalogue. This is how a downstream tool or a test supplies a translation without editing this file.
ARGUMENTS
$language- the language code; a new code creates a catalogue.\%entries- key to entry, in the shape described in "message".
RETURNS
The class name or object it was called on, so calls can be chained.
SIDE EFFECTS
Changes the catalogue for every translator in the process. A new language uses the English plural rule unless it is one of en, fr, de or zh.
EXAMPLE
App::Actions::I18N->add_messages(en => {
'demo.greeting' => {
gender => {
female => 'She ran %(count)d jobs',
male => 'He ran %(count)d jobs',
other => 'They ran %(count)d jobs',
},
},
});
API SPECIFICATION
INPUT
{
language => { type => 'string', matches => qr/\A[a-z]{2,3}\z/ },
entries => { type => 'hashref' },
}
OUTPUT
{ type => 'string|object' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to ... | Bad language code or entries | Pass a lower-case code and a |
| | not a hash reference | hash reference |
+--------------------------+-------------------------------+--------------------------------+
FUNCTIONS
All three are exported on request. i18n and throw are meant to be imported into a class, where they act as methods. The object's own i18n translator is used when it has one ($self->{i18n}), and the "shared" translator when it has not, which is also what a class-method call gets. validate_args is a plain function.
i18n
my $text = $self->i18n('workflow.empty', { file => $file });
Returns the rendered message; see "message".
API SPECIFICATION
INPUT
{
key => { type => 'string', min => 1 },
args => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string' }
MESSAGES
See "message".
EXAMPLE
package My::Class;
use App::Actions::I18N qw(i18n);
sub greet { my $self = shift; return $self->i18n('workflow.empty', { file => 'x' }) }
throw
$self->throw('workflow.no_jobs', { file => $file });
$self->throw('workflow.no_jobs', { file => $file, trace => 1 });
Croaks with the rendered message, reported from the perspective of the caller of the routine that threw it, exactly as croak in that routine would have. With trace => 1 in the arguments it confesses instead, so the message carries a full stack trace; there is no hand-rolled caller loop.
API SPECIFICATION
INPUT
{
key => { type => 'string', min => 1 },
args => { type => 'hashref', optional => 1 },
}
OUTPUT
Does not return.
MESSAGES
The message named by the key.
EXAMPLE
sub load {
my ($class, $file) = @_;
$class->throw('workflow.file_missing', { file => $file }) unless -f $file;
...
}
validate_args
my $params = validate_args(__PACKAGE__ . '::load', \@_, \%schema, 'file');
Normalises a routine's arguments with Params::Get and validates them with Params::Validate::Strict, croaking with the catalogue's actions.invalid_arguments message when they do not match. It is the one place the distribution turns a validator failure into a user-facing message, so every public routine reports bad arguments in the same words and the same language.
ARGUMENTS
$routine- the fully qualified routine name, for the message.\@args- the routine's arguments with the invocant removed.\%schema- a Params::Validate::Strict schema.$positional- optional; the Params::Get default: a key name for a lone positional argument, or an array reference of positional names.
RETURNS
The validated hash reference.
SIDE EFFECTS
None; croaks on invalid arguments.
EXAMPLE
sub load {
my $class = shift;
my $params = validate_args(__PACKAGE__ . '::load', \@_, {
file => { type => 'string', min => 1 },
}, 'file');
return $params->{file};
}
API SPECIFICATION
INPUT
{
routine => { type => 'string', min => 1 },
args => { type => 'arrayref' },
schema => { type => 'hashref' },
positional => { type => 'string|arrayref', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to | An argument is missing, of | Call the routine as its POD |
| ROUTINE: REASON | the wrong type, or unknown | documents |
+--------------------------+-------------------------------+--------------------------------+
LIMITATIONS
Coverage is partial. The catalogue holds the messages of App::Actions, App::Actions::Workflow, App::Actions::Action, App::Actions::Util, App::Actions::Runner and its parts, and the counted phrases of App::Actions::Cache and App::Actions::Logs. App::Actions::Check, App::Actions::Docker, App::Actions::Expression, App::Actions::Process and the command-line scripts still print English literals, so a non-English run prints a mixture.
The POSIX locale is ignored on purpose. Error text is matched by scripts in CI, and choosing a language from
LANGwould change it on any host whose locale is not English. SetACTION_RUN_LANGto opt in.Messages are character strings. A caller printing a non-English message to a handle without an encoding layer gets a "Wide character" warning; the command-line scripts do not yet set one, because they also print raw bytes captured from Docker, which an encoding layer would encode a second time.
The plural rules cover the integers the distribution counts with, not the full CLDR rules (no fractions, no "few"/"many" categories). A language added with "add_messages" uses the English rule.
The catalogue is process-wide. "add_messages" affects every translator, so two threads of a program that want different entries for the same key cannot both have them.
The French, German and Chinese texts were written without review by a native speaker.
COMMON PITFALLS
Set an encoding before you print. Messages are character strings, and French, German and Chinese texts contain non-ASCII characters. Without
binmode STDOUT, ':encoding(UTF-8)', Perl prints a "Wide character" warning.LANGdoes not choose the language. OnlyACTION_RUN_LANG(or the arguments) does. This keeps error messages in English on CI machines unless you ask.Placeholders need a conversion letter. Write
%(file)s, not%(file): without the letter, the text is printed as it is.A missing value is silent. If
\%argshas no value for a placeholder, a string placeholder becomes empty and a number placeholder becomes 0. There is no warning, so check your keys.A number placeholder needs a number.
%(count)dwithcount => 'three'prints 0, and theotherplural form is used.add_messageschanges every translator in the program, not just one object. In a test, restore what you changed."shared" follows the environment. If
ACTION_RUN_LANGchanges while the program runs, the next call to "shared" returns a new translator in the new language. Do not keep the object if you want that.throwdoes not return. Code after it in the same branch never runs.Keys are case sensitive.
Workflow.Emptyis notworkflow.empty.
ENCODING
All texts in the catalogues are Perl character strings (the language modules use use utf8). They may contain any Unicode character, including accented letters, Chinese characters and emoji. Values you pass in \%args may also contain any Unicode text; they are inserted as they are. The result is a character string: encode it when you print it or write it to a file. Keys and placeholder names must be ASCII. Language codes are two or three ASCII letters, country codes two ASCII letters.
AUTHOR
Nigel Horne, njh@nigelhorne.com
FORMAL SPECIFICATION
This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above. It is for readers who want to check exactly what a routine promises.
How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞState means the routine changes nothing, and ΔState means it can change the state. The lines below the rule are the conditions that are always true when the routine returns.
new
LANG ::= en | fr | de | zh
COUNTRY == seq CHAR
┌─ New ────────────────────────────────────────────
│ language? : optional seq CHAR
│ country? : optional COUNTRY
│ lang! : LANG
├───────────────────────────────────────────────────
│ let wanted ==
│ if language? ≠ ⊥ then base(language?)
│ else if country? ≠ ⊥ ∧ upper(country?) ∈ dom countryLanguage
│ then countryLanguage(upper(country?))
│ else if config.language ≠ ⊥ then base(config.language)
│ else if env(ACTION_RUN_LANG) ≠ ⊥ then base(env(ACTION_RUN_LANG))
│ else en
│ wanted ∈ dom catalogue ⇒ lang! = wanted
│ wanted ¬in; dom catalogue ⇒ lang! = en
└───────────────────────────────────────────────────
shared
┌─ Default ─────────────────────────────────────────
│ ΔCache
│ t! : Translator
├───────────────────────────────────────────────────
│ cache ≠ ⊥ ∧ cache.source = currentSource ⇒ t! = cache ∧ cache' = cache
│ cache = ⊥ ∨ cache.source ≠ currentSource ⇒ t! = New(∅) ∧ cache' = t!
└───────────────────────────────────────────────────
language
┌─ Language ────────────────────────────────────────
│ ΞTranslator
│ lang! : LANG
├───────────────────────────────────────────────────
│ lang! = θTranslator.lang
└───────────────────────────────────────────────────
languages
┌─ Languages ───────────────────────────────────────
│ ΞCatalogue
│ langs! : seq LANG
├───────────────────────────────────────────────────
│ ran langs! = dom catalogue ∧ langs! is sorted
└───────────────────────────────────────────────────
catalogue_keys
┌─ CatalogueKeys ───────────────────────────────────
│ ΞCatalogue
│ lang? : seq CHAR
│ keys! : seq KEY
├───────────────────────────────────────────────────
│ lang? ∈ dom catalogue ⇒ ran keys! = dom catalogue(lang?) ∧ keys! is sorted
│ lang? ¬in; dom catalogue ⇒ keys! = ⟨⟩
└───────────────────────────────────────────────────
language_for_country
┌─ LanguageForCountry ──────────────────────────────
│ country? : COUNTRY
│ lang! : optional LANG
├───────────────────────────────────────────────────
│ #country? = 2
│ upper(country?) ∈ dom countryLanguage ⇒ lang! = countryLanguage(upper(country?))
│ upper(country?) ¬in; dom countryLanguage ⇒ lang! = ⊥
└───────────────────────────────────────────────────
message
ENTRY ::= text⟨⟨STRING⟩⟩ | plural⟨⟨CATEGORY ⇸ ENTRY⟩⟩ | gender⟨⟨GENDER ⇸ ENTRY⟩⟩
┌─ Message ─────────────────────────────────────────
│ ΞTranslator
│ key? : KEY
│ args? : NAME ⇸ VALUE
│ text! : STRING
├───────────────────────────────────────────────────
│ key? ∈ dom catalogue(lang)
│ ⇒ text! = render(select(catalogue(lang)(key?), args?), args?)
│ key? ¬in; dom catalogue(lang) ∧ key? ∈ dom catalogue(en)
│ ⇒ text! = render(select(catalogue(en)(key?), args?), args?)
│ key? ¬in; dom catalogue(lang) ∪ dom catalogue(en)
│ ⇒ text! = key?
└───────────────────────────────────────────────────
add_messages
┌─ AddMessages ─────────────────────────────────────
│ ΔCatalogue
│ lang? : LANG
│ entries? : KEY ⇸ ENTRY
├───────────────────────────────────────────────────
│ catalogue' = catalogue ⊕ {lang? ↦ (catalogue(lang?) ⊕ entries?)}
└───────────────────────────────────────────────────
i18n
┌─ I18n ────────────────────────────────────────────
│ self? : Object
│ text! : STRING
├───────────────────────────────────────────────────
│ self?.i18n ≠ ⊥ ⇒ text! = Message[self?.i18n/θTranslator]
│ self?.i18n = ⊥ ⇒ text! = Message[Shared/θTranslator]
└───────────────────────────────────────────────────
throw
┌─ Throw ───────────────────────────────────────────
│ self? : Object
│ key? : KEY
│ args? : NAME ⇸ VALUE
├───────────────────────────────────────────────────
│ raises Exception(I18n(self?, key?, args?))
└───────────────────────────────────────────────────
validate_args
┌─ ValidateArgs ────────────────────────────────────
│ routine? : seq CHAR
│ args? : seq VALUE
│ schema? : NAME ⇸ RULE
│ params! : NAME ⇸ VALUE
├───────────────────────────────────────────────────
│ let p == getParams(positional?, args?)
│ satisfies(p, schema?) ⇒ params! = coerce(p, schema?)
│ ¬ satisfies(p, schema?) ⇒ raises InvalidArguments(routine?)
└───────────────────────────────────────────────────
STATE DIAGRAM
A translator object never changes its language after it is made. The catalogue (shared by every translator) and the "shared" translator do change:
Choosing a language (new):
language arg? --yes--> use it
| no
country arg? --yes--> language_for_country(country)
| no
$config{language}? --yes--> use it
| no
ACTION_RUN_LANG? --yes--> use it
| no
English
|
v reduce to the base code (de_DE.UTF-8 -> de)
catalogue for it? --no--> warn; use English
| yes
v
+-----------------+
| TRANSLATOR | language fixed
+-----------------+
Rendering a message (message / i18n / throw):
key in the language's catalogue? --no--> key in English? --no--> warn;
| yes | yes text = the key
v v
entry --------------------------------> entry
|
v
gender form (args.gender) -> plural form (args.count, language rule)
|
v
fill in %(name)s placeholders -> text
|
+-- message / i18n: return the text
+-- throw: die with App::Actions::Error(code = key, message = text,
location = caller's " at FILE line N.")
The shared translator:
+-----------+ shared() +-----------------+ shared() with the same
| NONE |----------->| CACHED (for | config{language} and
+-----------+ | config+ENV) |<-+ ACTION_RUN_LANG
+-----------------+--+
|
| config{language} or ACTION_RUN_LANG
| changed, then shared()
v
new translator replaces the cached one
The catalogue:
+----------------+ add_messages($lang, \%entries) +----------------+
| BUILT-IN |--------------------------------->| EXTENDED |
| (en, fr, de, | (new language: created, with | (for every |
| zh) | the English plural rule) | translator) |
+----------------+ +----------------+
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.