NAME
App::WebPerimeterScan::I18N - message catalogue lookup and the shared error handler
VERSION
Version 0.001.0
SYNOPSIS
Render a message with values:
use App::WebPerimeterScan::I18N;
my $i18n = 'App::WebPerimeterScan::I18N';
print $i18n->i18n('scope.not_listed', {
host => 'evil.example',
source => 'scope.txt',
}), "\n";
Let the count choose between singular and plural:
# "1 endpoint defined in source was not scanned"
# "3 endpoints defined in source were not scanned"
print $i18n->i18n('source-routes-unvisited.title', { count => 3 }), "\n";
Choose the language, whatever the environment says:
$App::WebPerimeterScan::I18N::config{language} = 'de';
print $i18n->language, "\n"; # 'de' if a German catalogue exists, else 'en'
Remove the "at FILE line N." that croak adds, before you show an error to a person:
eval { $scope->load_file('missing.txt'); 1 }
or print App::WebPerimeterScan::I18N::clean_error($@), "\n";
Write a module that throws catalogued errors:
package App::WebPerimeterScan::Example;
use parent 'App::WebPerimeterScan::I18N';
sub load
{
my ($self, $file) = @_;
# croak()s with the catalogue text for the user's language
$self->_throw('scope.empty', { file => $file }) unless -s $file;
return $self;
}
# Any class or object in the distribution can render a message
print App::WebPerimeterScan::I18N->i18n('report.summary', { summary => '0 high' }), "\n";
DESCRIPTION
Every user-facing string in the distribution - finding titles, error messages, report labels and the command-line usage text - lives in App::WebPerimeterScan::Messages under a stable key. This class looks the key up for the user's language, fills in the placeholders and chooses the plural or gender form, so no other module holds English text.
It is also the base class of every module in the distribution, which is what gives each of them i18n and the protected _throw and _warn helpers. Those helpers are the only places that call croak and carp.
Message format
A message is either a string or a selector.
A string may contain {name}, replaced by the argument of that name, or {name:%.1f}, where the part after the colon is a sprintf format applied to the argument. A placeholder whose argument was not given is left in the text as it is, so a missing value is visible rather than silently empty.
A selector is a hash:
{ plural => 'count', forms => { one => '{count} file', other => '{count} files' } }
{ select => 'gender', forms => { female => 'her', male => 'his', other => 'their' } }
plural applies the language's plural rule to the named argument to choose the form; select uses the argument's value as the form name. Either falls back to other, and the chosen form may itself be a selector.
SUBROUTINES/METHODS
language
Purpose: report which catalogue i18n will read.
Args: none (callable as a class or an object method).
Returns: a language code such as en; always one that has a catalogue.
Side Effects: none. The environment is read on each call so a test can change LC_ALL between calls.
Usage: my $lang = App::WebPerimeterScan::I18N->language;
EXAMPLE
local $ENV{LC_ALL} = 'de_DE.UTF-8';
# 'en' until a German catalogue is installed
print App::WebPerimeterScan::I18N->language, "\n";
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', matches => qr/\A[a-z]{2,3}\z/ }
MESSAGES
None.
i18n
Purpose: return the text of a message key in the user's language, with its placeholders filled in.
Args: $key (string) and an optional hash reference of named arguments.
Returns: the rendered string.
Side Effects: confesses when no catalogue - not even the fallback - holds the key, because that is a programming error and the stack trace is what finds it.
Usage: $self->i18n('scope.empty', { file => $file })
EXAMPLE
my $text = App::WebPerimeterScan::I18N->i18n(
'source-routes-unvisited.title', { count => 3 },
);
# "3 endpoints defined in source were not scanned"
API SPECIFICATION
INPUT
{
key => { type => 'string', min => 1, position => 0 },
args => { type => 'hashref', optional => 1, position => 1 },
}
OUTPUT
{ type => 'string' }
MESSAGES
Message Meaning / resolution
----------------------------------- ---------------------------------------
No message is defined for "KEY" The key is in no catalogue. Add it to
App::WebPerimeterScan::Messages (en).
PSEUDOCODE
look the key up in the user's catalogue, then in the fallback
confess if neither has it
while the message is a selector
choose the form named by the plural rule or the argument value
use 'other' if that form does not exist
replace each {name} or {name:format} that has an argument
has_message
Purpose: say whether a key exists, for optional parts of a message family such as a finding's .fix text.
Args: $key (string).
Returns: 1 if the user's catalogue or the fallback has the key, else 0.
Side Effects: none.
Usage: $self->i18n("$id.fix") if $self->has_message("$id.fix")
EXAMPLE
print "has a fix\n" if App::WebPerimeterScan::I18N->has_message('csp-missing.fix');
API SPECIFICATION
INPUT
{ key => { type => 'string', min => 1, position => 0 } }
OUTPUT
{ type => 'boolean' }
MESSAGES
None.
clean_error
Purpose: turn an exception into a one-line message for a person, by removing the at FILE line N. suffix that croak and die append.
Args: the exception: a string, or an object that stringifies, such as an autodie::exception.
Returns: the message without the location or trailing newline; an empty string for undef.
Side Effects: none.
Usage: print STDERR clean_error($@), "\n" if $@;
EXAMPLE
eval { $scope->load_file('missing.txt') };
print App::WebPerimeterScan::I18N::clean_error($@), "\n";
# Cannot read scope file 'missing.txt': No such file or directory
API SPECIFICATION
INPUT
{ error => { type => 'scalar|object', optional => 1, position => 0 } }
OUTPUT
{ type => 'string' }
MESSAGES
None.
COMMON PITFALLS
A missing key is fatal; a missing value is not.
i18nwith a key that no catalogue has dies with a stack trace, because that is a bug in the code. A placeholder with no value (or anundefvalue) stays in the text as{name}, because the message is still useful.A format must suit its value.
{seconds:%.1f}with the value'soon'prints0.0and a Perl warning "isn't numeric". Pass numbers to numeric formats.Give every selector an
otherform. When the chosen form is missing, theotherform is used. Whenotheris missing too, the message is the empty string, with no warning.A count that is not a number is plural.
{ count ='many' }> and a missingcountboth choose theotherform.A new language needs a plural rule. A catalogue for a language with no entry in
%App::WebPerimeterScan::I18N::PLURAL_RULEuses the English rule (1 isone, every other number isother). That is wrong for languages such as Polish or Arabic, and for Japanese, which has no plural.The order of the environment variables matters. The first language found wins, in this order:
$config{language}, thenLANGUAGE(which may be a list such asde:fr), thenLC_ALL,LC_MESSAGESandLANG. The valuesCandPOSIXare skipped. A language with no catalogue is skipped too, so the answer is oftenen.clean_erroris a function, not a method. Call it asApp::WebPerimeterScan::I18N::clean_error($@). It only removes Perl's location suffix; the message text is not changed.
ENCODING
Catalogue text is bytes. Messages.pm does not say
use utf8, so a translation written in UTF-8 in that file is a UTF-8 byte string. That matches the rest of the distribution, where page text and file names are byte strings too, and the reports come out as correct UTF-8. Any language and emoji can be used this way.Do not add
use utf8to a catalogue. It makes the messages character strings. Joined with the byte strings from web pages, the bytes are then encoded a second time, and the report showséforé.Values are inserted as they are. A value that is a byte string stays a byte string. A value that is a character string makes the whole message a character string; print it through an
:encoding(UTF-8)layer.sprintfwidths count bytes.{name:%-10s}pads a UTF-8 byte string by bytes, so a name with non-ASCII letters looks shorter than ten letters.Keys and placeholder names are ASCII: letters, digits,
_,.and-.
LIMITATIONS
Only an English catalogue ships. The lookup, plural and select machinery is language-neutral and falls back key by key, but a German or Japanese user sees English until a catalogue is added to
%App::WebPerimeterScan::Messages::CATALOGUEtogether with a%PLURAL_RULEentry.Locale::Maketext and Locale::TextDomain were considered and not used. Maketext takes positional arguments (
[_1]) where this API takes a hash of named ones, and has no select form for gender; TextDomain needs compiled.mofiles, which is a build step out of proportion to a single language. The renderer here is about twenty lines.The language is read from the environment on every call rather than cached, so a long-running caller that changes
LC_ALLgets the new language at once. The cost is a few hash lookups per message.
FORMAL SPECIFICATION
This section states exactly what each method does, in the Z notation. You do not need it to use the module: the method descriptions above say the same thing in words. It is here for reviewers and for anyone who writes tests.
How to read a schema box:
The part above the middle line declares names. A name that ends in
?is an input. A name that ends in!is an output.The part below the middle line lists facts that are true after the call.
A name with a dash after it, such as
items', is the value after the call. The same name without the dash is the value before the call.Delta X(written with the Greek capital delta) means the call may change the stateX.Xi X(the Greek capital xi) means the call does not change it.
The symbols used are:
Δ may change Ξ does not change • such that
∀ for all ∃ there is ¬ not
∧ and ∨ or ⇒ implies
⇔ if and only if ≠ not equal ≤ ≥ at most, at least
∈ is in ¬in; is not in ⊆ is a subset of
∪ union ∩ intersection ∅ the empty set
ℙ X sets of X ⊥ undefined (undef) … and so on
⟨⟩ the empty sequence ⁀ join sequences ⇸ partial mapping
↦ maps to ⊕ override a mapping ↾ keep only these keys
∘ compose → function × ÷ − times, divide, minus
ℕ 0, 1, 2, ... ℕ₁ 1, 2, 3, ... ℝ real numbers
𝔹 true or false ⌊x⌋ x rounded down
dom M the keys of M ran M the values of M #S the size of S
language
┌─ Language ──────────────────────────────────────────
│ Ξenvironment; catalogue : LANG ⇸ MESSAGES
│ result! : LANG
├─────────────────────────────────────────────────────
│ prefs = ⟨ configured ⟩ ⁀ wanted(environment)
│ (∃ l ∈ ran prefs ∩ dom catalogue ⇒
│ result! = head(prefs ↾ dom catalogue))
│ (ran prefs ∩ dom catalogue = ∅ ⇒ result! = fallback)
└─────────────────────────────────────────────────────
i18n
┌─ I18n ──────────────────────────────────────────────
│ ΞCatalogue
│ key? : KEY; args? : NAME ⇸ VALUE; result! : STRING
├─────────────────────────────────────────────────────
│ key? ∈ dom catalogue(language) ∪ dom catalogue(fallback)
│ m = (catalogue(language) ⊕ ∅) (key?) ⊕ catalogue(fallback)(key?)
│ result! = render(select(m, args?), args?)
│ ∀ p ∈ placeholders(result!) • p ¬in; dom args?
└─────────────────────────────────────────────────────
has_message
┌─ HasMessage ────────────────────────────────────────
│ ΞCatalogue; key? : KEY; result! : 𝔹
├─────────────────────────────────────────────────────
│ result! ⇔ key? ∈ dom catalogue(language) ∪ dom catalogue(fallback)
└─────────────────────────────────────────────────────
clean_error
┌─ CleanError ────────────────────────────────────────
│ error? : STRING; result! : STRING
├─────────────────────────────────────────────────────
│ ∃ loc : LOCATION • error? = result! ⁀ loc ∨ error? = result!
│ ¬ (∃ loc : LOCATION • suffix(result!) = loc)
└─────────────────────────────────────────────────────
STATE DIAGRAM
The class keeps no state of its own. It reads %config, the environment and the catalogue every time. One call to i18n goes through these steps:
i18n(key, args)
|
v
+----------------+
| CHOOSE | first of: $config{language}, LANGUAGE (a list),
| LANGUAGE | LC_ALL, LC_MESSAGES, LANG that has a catalogue;
+----------------+ 'C' and 'POSIX' skipped; else the fallback 'en'
|
v
+----------------+
| LOOK UP KEY |--- not in that language, and not in 'en'
+----------------+ -> confess (dies with a stack trace)
|
| found (in that language, or else in 'en')
v
+----------------+
| SELECT FORM |<---------------------------------+
+----------------+ |
| | |
| a string | a selector |
| v |
| plural: the language's rule on args{name} |
| select: the value of args{name} |
| form missing -> 'other' |
| 'other' missing -> '' |
| | |
| | the chosen form is a selector -----+
| |
| | the chosen form is a string
v v
+----------------+
| FILL IN | each {name} or {name:%fmt} that has a defined
| PLACEHOLDERS | value is replaced, in one pass; the others stay
+----------------+
|
v
the message (a string)
The protected helpers add one step after that:
_throw(key, args) -> i18n(key, args) -> croak (the caller dies)
_warn(key, args) -> i18n(key, args) -> carp (returns the object)
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.