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. i18n with 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 an undef value) stays in the text as {name}, because the message is still useful.

  • A format must suit its value. {seconds:%.1f} with the value 'soon' prints 0.0 and a Perl warning "isn't numeric". Pass numbers to numeric formats.

  • Give every selector an other form. When the chosen form is missing, the other form is used. When other is missing too, the message is the empty string, with no warning.

  • A count that is not a number is plural. { count = 'many' }> and a missing count both choose the other form.

  • A new language needs a plural rule. A catalogue for a language with no entry in %App::WebPerimeterScan::I18N::PLURAL_RULE uses the English rule (1 is one, every other number is other). 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}, then LANGUAGE (which may be a list such as de:fr), then LC_ALL, LC_MESSAGES and LANG. The values C and POSIX are skipped. A language with no catalogue is skipped too, so the answer is often en.

  • clean_error is a function, not a method. Call it as App::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 utf8 to 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.

  • sprintf widths 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::CATALOGUE together with a %PLURAL_RULE entry.

  • 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 .mo files, 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_ALL gets 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 state X. 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.