NAME

App::WebPerimeterScan::Messages - the message catalogue

VERSION

Version 0.001.0

SYNOPSIS

Read one message, without the placeholders filled in:

use App::WebPerimeterScan::Messages;

my $en = $App::WebPerimeterScan::Messages::CATALOGUE{en};
print $en->{'csp-missing.title'}, "\n";

Add a partial German catalogue. Every key it does not have falls back to English:

$App::WebPerimeterScan::Messages::CATALOGUE{de} = {
	'csp-missing.title' => 'Content-Security-Policy fehlt',
	'report.no_findings' => 'Keine Befunde.',
};

Add a message with a plural form:

$App::WebPerimeterScan::Messages::CATALOGUE{de}{'source-routes-unvisited.title'} = {
	plural => 'count',
	forms  => {
		one   => '{count} Endpunkt wurde nicht gescannt',
		other => '{count} Endpunkte wurden nicht gescannt',
	},
};

Translate a saved JSON report afterwards, using the rule ids in it:

my $i18n   = 'App::WebPerimeterScan::I18N';
my $report = JSON::PP::decode_json($json_text);
for my $finding (@{ $report->{findings} }) {
	my $key = "$finding->{id}.title";
	# A title may need values (such as {url}) that the report does not
	# keep, so fall back to the English title already in the report.
	my $title = $i18n->has_message($key) ? $i18n->i18n($key, $finding) : $finding->{title};
	print "$title\n";
}

DESCRIPTION

This module holds every piece of text that the distribution shows to a person: finding titles and advice, error messages, warnings, report labels and the command-line help. No other module contains English sentences.

The text is in one hash, %App::WebPerimeterScan::Messages::CATALOGUE. Its keys are language codes (en), and each value is a hash of message key to message. How a message is written (placeholders, plural and select forms) and how the language is chosen are described in App::WebPerimeterScan::I18N.

Message keys

Keys are short, stable names made of words joined by dots, such as scope.empty or report.no_findings. The first word is the part of the program that uses the message.

The text of a finding uses the finding's rule id as the first part:

<rule id>.title      required; one line
<rule id>.detail     required; why the problem matters
<rule id>.fix        optional; what to do about it
<rule id>.evidence   optional; a template for the evidence line

The rule id is the id field of each finding, so a saved JSON report can be translated later by looking its ids up here. The one exception is the port rule: its ids are port-open-22, port-open-3306 and so on, and all of them share the messages under port-open.

This module has no subroutines.

COMMON PITFALLS

  • Do not replace the English catalogue. English is the fallback for every other language. A key removed from en makes every language die when that message is needed. Add to it, or add a new language.

  • A new language replaces nothing until it is chosen. Adding $CATALOGUE{de} does not change the output for a user whose environment says en_GB. See "language" in App::WebPerimeterScan::I18N.

  • Keep every placeholder. A translation that leaves out {host} loses that value, with no warning. A translation that adds a placeholder the code does not pass shows it as {name}.

  • A finding needs both .title and .detail. The test t/i18n.t fails when the code uses a rule id that does not have them.

  • %CATALOGUE is a normal hash. Any code can change it at any time, and the change is seen by every later message.

ENCODING

  • This file is ASCII, and the English catalogue is ASCII.

  • A translation may use any UTF-8 text, including emoji. Write it as UTF-8 bytes, as Messages.pm does: do not add use utf8. The reason is explained in "ENCODING" in App::WebPerimeterScan::I18N.

  • A translation added at run time from a decoded source (for example a JSON file read with decode_json) is made of character strings. Encode each string to UTF-8 bytes before you add it.

LIMITATIONS

Only English (en) is provided.

FORMAL SPECIFICATION

The catalogue is data, not code. This schema states the rules it must follow. t/i18n.t checks the last two for every key the code uses. The symbols are explained in "FORMAL SPECIFICATION" in App::WebPerimeterScan::I18N. MESSAGE ::= ... defines a type with three kinds of value; the double angle brackets hold the data each kind carries.

CATALOGUE

┌─ Catalogue ─────────────────────────────────────────
│ CATALOGUE : LANGUAGE ⇸ (KEY ⇸ MESSAGE)
│ MESSAGE ::= text⟨⟨STRING⟩⟩
│           | plural⟨⟨NAME × (FORM ⇸ MESSAGE)⟩⟩
│           | select⟨⟨NAME × (FORM ⇸ MESSAGE)⟩⟩
├─────────────────────────────────────────────────────
│ en ∈ dom CATALOGUE
│ ∀ k : used_keys • k ∈ dom CATALOGUE(en)
│ ∀ r : rule_ids • r.title ∈ dom CATALOGUE(en)
│                ∧ r.detail ∈ dom CATALOGUE(en)
└─────────────────────────────────────────────────────

STATE DIAGRAM

The catalogue is filled once, when the module is loaded. After that it changes only when some code assigns to it.

  use App::WebPerimeterScan::Messages
        |
        v
  +----------------+
  |  ENGLISH ONLY  |  CATALOGUE = { en => { ... } }
  +----------------+
        |
        |  code assigns $CATALOGUE{xx} = { ... }
        v
  +----------------+
  | MORE LANGUAGES |  CATALOGUE = { en => ..., xx => ... }
  +----------------+
        |      ^
        +------+  code adds or changes a key, or another language

Every i18n() call reads the catalogue as it is at that moment.

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.