Implementing i18n in a Dancer application using Plugin::LogReport

Introduction

There are a number of ways to approach the internationalisation of a web application. I'm going to look at a method using Log::Report, which includes very comprehensive translation functionality.

More specifically, I will use its Dancer2 plugin, Dancer2::Plugin::LogReport. If you haven't already, please look at my other article on that.

A basic application

Let's get started by creating a basic application with some strings that we want translated. We'll look at translating the content of templates later.

# In your route handler
package MyApp;
use Dancer2;
use Dancer2::Plugin::LogReport 'my-domain'; # domain used to group sets of messages

get '/' => sub {
    notice "Hello world";
    template 'index';
};

# In the index template
[% FOREACH message IN session.messages %]
    <p>[% message.toString %]</p>
[% END %]

By running this application, you should see Hello world on the index page.

Translating strings

Log::Report has a lot of functions to translate strings. We'll touch on 2 of the commonly used ones here.

To simply translate a fixed string into another, we can use __ (2 underscores):

notice __"Hello world";

This might look a bit strange, but the 2 underscores are simply a function. You can wrap the text in parentheses if you prefer:

notice __("Hello world");

Both the parent Log::Report module and its inspiration Locale::TextDomain encourage use of the functions without parentheses though, as it makes the code a more readable.

Note that you can't use single quotes, as __' is interpreted as :: by Perl

We can also use __x, which is the same as __, except that it allows for variables within the string. For example:

notice __x"Hello {name}", name => 'Andy';

This means that when the text is extracted as message IDs, any variable content is conveniently separated. It also allows all sorts of clever things, such as adjusting the text for pluralisation depending on variable conditions, but I won't go into that just yet.

So, for now, update your web application accordingly:

get '/' => sub {
    notice __"Hello world";
    notice __x"Hello {name}", name => 'Andy';
    template 'index';
};

The beauty of the above approach, is that initially you can just add (very easily and unobtrusively) the translation functions to all your strings. Then, at a later date, you can add the translation framework.

Extracting and translating the strings

In order to translate the text in the application, we will need some translation tables. We will produce these below.

Extracting and creating PO translation tables

An initial template for the tables can be extracted using xgettext-perl. From the base directory of your application run:

xgettext-perl -p messages --from-code=iso-8859-1 .

This will scan the current directory for appropriate Perl files and create a new directory messages with the strings to be translated as message IDs in a default .po file. For more information on the module used to extract the strings, see Log::Report::Extract::PerlPPI. Note that the domain specified in the options for Dancer2::Plugin::LogReport (or Log::Report) is used within the filename. Domains can be used to group together sets of messages (such as if you want to separate groups of messages for different modules).

For the purposes of this exercise, a PO file should have been created containing the 2 message IDs ("Hello world" and "Hello {name}"). From this default PO file, create a specific language file in a new directory for this domain:

mkdir messages/my-domain/
cp messages/my-domain.utf-8.po messages/my-domain/de_DE.utf8.po

Now edit the file, inserting the translation text into the existing lines (the "fuzzy" flag can be removed to indicate that the text has been translated):

#: ./lib/Advent.pm:11
#,
msgid "Hello world"
msgstr "Hallo Welt"
#: ./lib/Advent.pm:12
#,
msgid "Hello {name}"
msgstr "Hallo {name}"

Adding a translator to your application

To add a translator, add the following code to your route handler:

use Log::Report::Translator::POT;

(textdomain 'my-domain')->configure(
    translator => Log::Report::Translator::POT->new(
        lexicon => 'messages' # Directory of the PO files
    )
);

Now run your application, this time specifying another language:

LC_MESSAGES=de_DE.utf8 perl bin/app.pl

When you visit the index page, the text should now have been appropriately translated.

Translating text within templates

Translating text within templates is very similar to translating strings within the application, except that being a template, the translation functions aren't quite so readily available. For the purposes of this example, I'm going to assume that you're using Template::Toolkit.

As we don't have a readily available translating function, we'll do things the other way around this time, and start by providing a translating function to each template. Add the following to your route handler:

sub translate {
    my $msg = Log::Report::Message->fromTemplateToolkit('tt-domain', @_);
    $msg->toString;
}
 
hook before_template => sub {
    shift->{loc}  = \&translate;
};

The above code adds a subroutine loc that can be called from within each template. Note that we are using a different domain; this is good practive in order to keep the sets of messages completely separate, but it's also possible to use the same domain if you prefer.

As a simple example, add the following to your index template:

[% loc("We are dancing") %]

(See Log::Report::Extract::Template for more powerful examples.)

Again, we'll need to extract the message IDs for translation. This can be done in a similar manner to last time, but this time specifying template files:

xgettext-perl -p messages --from-code=iso-8859-1 --template TT2-loc --domain tt-domain .

Note that if you are using the same domain, then this will remove message strings from your original extractions (unless the same messages appear in the template files). To prevent this, you can you use the --no-cleanup option, in which case the previous messages will be commented out. As per the comment above, it's best to use separate domains for this reason.

Repeat the previous process, copying the default translation to a German file in the new domain, and add the translation:

mkdir messages/tt-domain/
cp messages/tt-domain.utf-8.po messages/tt-domain/de_DE.utf8.po

msgid "We are dancing"
msgstr "Wir tanzen"

Finally add a new translator for the new domain:

(textdomain 'tt-domain')->configure(
    translator => Log::Report::Translator::POT->new(
        lexicon => 'messages' # Directory of the PO files
    )
);

Re-run the application, and both the previous string as well as new template text should be translated.

Handling plurals

Log::Report has several translating functions, all detailed in the documentation. To give an example, here's code that handles pluralisation:

my @files = ("File") x (int(rand(2))+1);
notice __xn"Saved one file", "Saved {_count} files", @files;

Author

This article has been written by Andy Beverley for the Perl Dancer Advent Calendar 2016.

Copyright

No copyright retained. Enjoy.