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 language argument to "new";
2. the country argument to "new" (a two-letter country code such as GB or cn; upper or lower case);
3. $App::Actions::I18N::config{language};
4. the ACTION_RUN_LANG environment 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 as fr or de_DE. Only the part before _ or - is used.

  • country - optional; an ISO 3166-1 alpha-2 country code. Ignored when language is 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 as workflow.no_jobs.

  • \%args - optional; the values of the placeholders. count chooses the plural form and gender (female, male or other) 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 LANG would change it on any host whose locale is not English. Set ACTION_RUN_LANG to 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.

  • LANG does not choose the language. Only ACTION_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 \%args has 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)d with count => 'three' prints 0, and the other plural form is used.

  • add_messages changes every translator in the program, not just one object. In a test, restore what you changed.

  • "shared" follows the environment. If ACTION_RUN_LANG changes 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.

  • throw does not return. Code after it in the same branch never runs.

  • Keys are case sensitive. Workflow.Empty is not workflow.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.