SymfonyWorld Online 2020
100% online
30+ talks + workshops
Live + Replay watch talks later

Passo 28: Localizzazione di un’applicazione

5.0 version
Maintained

Localizzazione di un’applicazione

Con un pubblico internazionale, Symfony è stato in grado di gestire l’internazionalizzazione (i18n) e la localizzazione (l10n) fin dalla prima versione. La localizzazione di un’applicazione non riguarda solo la traduzione dell’interfaccia, ma anche la gestione dei plurali, la formattazione delle date e delle valute, gli URL e altro ancora.

Internazionalizzazione degli URL

Il primo passo per internazionalizzare il sito è quello di internazionalizzare gli URL. Quando si traduce un’interfaccia di un sito, gli URL dovrebbero essere differenti a seconda della localizzazione per poter sfruttare la cache HTTP (non bisogna mai usare lo stesso URL per localizzazioni differenti e memorizzare la localizzazione nei dati di sessione).

Utilizzare il parametro speciale _locale per fare riferimento alla localizzazione nelle rotte:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/", name="homepage")
+     * @Route("/{_locale}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Nella homepage, la localizzazione ora è impostata a seconda dell’URL; per esempio, se si visita la pagina /fr/, il metodo $request->getLocale() restituisce il valore fr.

Probabilmente non sarete in grado di tradurre i contenuti in tutte le lingue, limitatevi a quelle che volete supportare:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale}/", name="homepage")
+     * @Route("/{_locale<en|fr>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Ogni parametro delle rotte può essere limitato da un’espressione regolare, inserita tra < e >. La rotta homepage viene utilizzata solo quando il parametro _locale ha come valore en oppure fr. Provate a visitare la pagina /es/ e dovreste ricevere un errore 404 in quanto non esiste un rotta corrispondente.

Dato che useremo lo stesso requisito in quasi tutte le rotte, possiamo configurare il parametro nel container:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -7,6 +7,7 @@ parameters:
     default_admin_email: [email protected]om
     default_domain: '127.0.0.1'
     default_scheme: 'http'
+    app.supported_locales: 'en|fr'

     router.request_context.host: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
     router.request_context.scheme: '%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -34,7 +34,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/{_locale<en|fr>}/", name="homepage")
+     * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */
     public function index(ConferenceRepository $conferenceRepository)
     {

Si può aggiungere una lingua nel parametro app.supported_languages.

Aggiungere lo stesso prefisso alle rotte degli altri URL:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -47,7 +47,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference_header", name="conference_header")
+     * @Route("/{_locale<%app.supported_locales%>}/conference_header", name="conference_header")
      */
     public function conferenceHeader(ConferenceRepository $conferenceRepository)
     {
@@ -60,7 +60,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference/{slug}", name="conference")
+     * @Route("/{_locale<%app.supported_locales%>}/conference/{slug}", name="conference")
      */
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir)
     {

Abbiamo quasi finito. Non abbiamo più una rotta che corrisponde a /. Aggiungiamola di nuovo facendola reindirizzare a /en/:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,14 @@ class ConferenceController extends AbstractController
         $this->bus = $bus;
     }

+    /**
+     * @Route("/")
+     */
+    public function indexNoLocale()
+    {
+        return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+    }
+
     /**
      * @Route("/{_locale<%app.supported_locales%>}/", name="homepage")
      */

Ora che tutte le rotte principali utilizzano la localizzazione, possiamo notare che gli URL generati per le pagine prendono automaticamente in considerazione la localizzazione corrente.

Aggiungere un selettore di localizzazione

Per consentire agli utenti di passare dalla localizzazione en predefinita a un’altra, aggiungiamo un selettore di localizzazione nell’intestazione:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -34,6 +34,16 @@
                                     Admin
                                 </a>
                             </li>
+<li class="nav-item dropdown">
+    <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
+        data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        English
+    </a>
+    <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
+        <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>
+        <a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'}) }}">Français</a>
+    </div>
+</li>
                         </ul>
                     </div>
                 </div>

Per passare a un’altra localizzazione, passiamo esplicitamente il parametro _locale alla funzione path().

Aggiornare il template per visualizzare il nome della localizzazione corrente, sostituendo il valore «English»:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
 <li class="nav-item dropdown">
     <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
         data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        English
+        {{ app.request.locale|locale_name(app.request.locale) }}
     </a>
     <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
         <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>

app è una variabile globale di Twig che dà accesso alla richiesta web corrente. Per convertire la localizzazione in una stringa leggibile, usiamo il filtro locale_name di Twig.

A seconda della localizzazione, il nome della localizzazione visualizzata non è sempre in maiuscolo. Per trasformare le frasi con l’iniziale maiuscola, abbiamo bisogno di un filtro che gestisca i caratteri Unicode. Possiamo utilizzare il componente String (fornito da Symfony) e dalla sua integrazione con Twig:

1
$ symfony composer req twig/string-extra
patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
 <li class="nav-item dropdown">
     <a class="nav-link dropdown-toggle" href="#" id="dropdown-language" role="button"
         data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        {{ app.request.locale|locale_name(app.request.locale) }}
+        {{ app.request.locale|locale_name(app.request.locale)|u.title }}
     </a>
     <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
         <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a>

Ora è possibile passare dal francese all’inglese tramite il selettore e l’intera interfaccia si adatta di conseguenza e piuttosto bene:

Traduzione dell’interfaccia

Per iniziare a tradurre il sito, è necessario installare il componente Translation di Symfony:

1
$ symfony composer req translation

Tradurre ogni singola frase su di un sito web di grandi dimensioni può essere noioso, ma fortunatamente abbiamo solo una manciata di messaggi sul nostro sito. Cominciamo con tutte le frasi della homepage:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -20,7 +20,7 @@
             <nav class="navbar navbar-expand-xl navbar-light bg-light">
                 <div class="container mt-4 mb-3">
                     <a class="navbar-brand mr-4 pr-2" href="{{ path('homepage') }}">
-                        &#128217; Conference Guestbook
+                        &#128217; {{ 'Conference Guestbook'|trans }}
                     </a>

                     <button class="navbar-toggler border-0" type="button" data-toggle="collapse" data-target="#header-menu" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Afficher/Cacher la navigation">
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -4,7 +4,7 @@

 {% block body %}
     <h2 class="mb-5">
-        Give your feedback!
+        {{ 'Give your feedback!'|trans }}
     </h2>

     {% for row in conferences|batch(4) %}
@@ -21,7 +21,7 @@

                             <a href="{{ path('conference', { slug: conference.slug }) }}"
                                class="btn btn-sm btn-blue stretched-link">
-                                View
+                                {{ 'View'|trans }}
                             </a>
                         </div>
                     </div>

Il filtro trans di Twig permette di ottenere la traduzione di un valore nella localizzazione corrente. Se non viene trovata una traduzione, viene usata la localizzazione predefinita (default locale), come configurato in config/packages/translation.yaml:

1
2
3
4
5
6
framework:
    default_locale: en
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks:
            - en

Si noti che la «scheda» della barra degli strumenti di debug, relativa alla traduzione, è diventata rossa:

Ci dice che ci sono 3 messaggi non ancora tradotti.

Fare clic sulla «scheda» per elencare tutti i messaggi per i quali Symfony non ha trovato una traduzione:

Aggiungere le traduzioni

Come abbiamo visto nella configurazione in config/packages/translation.yaml, le traduzioni sono memorizzate in una cartella translations/, creata automaticamente.

Invece di creare manualmente i file di traduzione, possiamo utilizzare il comando translation:update:

1
$ symfony console translation:update fr --force --domain=messages

Questo comando genera un file di traduzione (col parametro --force) per la lingua fr e il contesto messages. Il contesto messages contiene tutti i messaggi dell’applicazione escludendo quelli provenienti da Symfony stesso come gli errori di validazione o di sicurezza.

Modificare il file translations/messages+intl-icu.fr.xlf e tradurre i messaggi in francese. Non parli francese? Lascia che ti aiuti:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -7,15 +7,15 @@
     <body>
       <trans-unit id="LNAVleg" resname="Give your feedback!">
         <source>Give your feedback!</source>
-        <target>__Give your feedback!</target>
+        <target>Donnez votre avis !</target>
       </trans-unit>
       <trans-unit id="3Mg5pAF" resname="View">
         <source>View</source>
-        <target>__View</target>
+        <target>Sélectionner</target>
       </trans-unit>
       <trans-unit id="eOy4.6V" resname="Conference Guestbook">
         <source>Conference Guestbook</source>
-        <target>__Conference Guestbook</target>
+        <target>Livre d'Or pour Conferences</target>
       </trans-unit>
     </body>
   </file>

Si noti che non tradurremo tutti i template, ma sentitevi liberi di farlo:

Tradurre i form

Le etichette dei form sono visualizzate automaticamente da Symfony tramite il sistema di traduzione. Andando alla pagina di una conferenza e cliccando sulla scheda «Translation» della barra degli strumenti di debug, dovremmo vedere tutte le etichette pronte per la traduzione:

Localizzare le date

Se si passa al francese e si va su una pagina delle conferenze che contiene dei commenti, si noterà che le date dei commenti sono localizzate in modo automatico. Questo avviene grazie al filtro format_datetime di Twig, che utilizza la localizzazione ({{ comment.createdAt|format_datetime('medium', 'short') }}).

La localizzazione funziona per date, orari (format_time), valute (format_currency) e numeri in generale (format_number) come percentuali, durate, ortografia, ecc.

Tradurre i plurali

La gestione dei plurali nelle traduzioni è un caso specifico del problema più generale della scelta di una traduzione basata su una condizione.

Nella pagina di una conferenza viene visualizzato il numero di commenti: There are 2 comments. Per un singolo commento, viene visualizzato There are 1 comments, che è sbagliato. Modifichiamo il template per convertire la frase in un messaggio traducibile:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -37,7 +37,7 @@
                         </div>
                     </div>
                 {% endfor %}
-                <div>There are {{ comments|length }} comments.</div>
+                <div>{{ 'nb_of_comments'|trans({count: comments|length}) }}</div>
                 {% if previous >= 0 %}
                     <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
                 {% endif %}

Per questo messaggio abbiamo utilizzato un’altra strategia di traduzione. Invece di mantenere la versione inglese nel template, l’abbiamo sostituito con un identificatore univoco. Questa strategia funziona meglio per un testo complesso e lungo.

Aggiorniamo il file di traduzione aggiungendo il nuovo messaggio:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -17,6 +17,10 @@
         <source>View</source>
         <target>Sélectionner</target>
       </trans-unit>
+      <trans-unit id="Dg2dPd6" resname="nb_of_comments">
+        <source>nb_of_comments</source>
+        <target>{count, plural, =0 {Aucun commentaire.} =1 {1 commentaire.} other {# commentaires.}}</target>
+      </trans-unit>
     </body>
   </file>
 </xliff>

Non abbiamo ancora finito, perché ora dobbiamo aggiungere la traduzione in inglese. Creiamo il file translations/messages+intl-icu.en.xlf:

translations/messages+intl-icu.en.xlf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
  <file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
    <header>
      <tool tool-id="symfony" tool-name="Symfony"/>
    </header>
    <body>
      <trans-unit id="maMQz7W" resname="nb_of_comments">
        <source>nb_of_comments</source>
        <target>{count, plural, =0 {There are no comments.} one {There is one comment.} other {There are # comments.}}</target>
      </trans-unit>
    </body>
  </file>
</xliff>

Aggiornamento dei test funzionali

Non dimentichiamo di aggiornare i test funzionali per riflettere i cambiamenti di contenuti e URL:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testIndex()
     {
         $client = static::createClient();
-        $client->request('GET', '/');
+        $client->request('GET', '/en/');

         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Give your feedback');
@@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testCommentSubmission()
     {
         $client = static::createClient();
-        $client->request('GET', '/conference/amsterdam-2019');
+        $client->request('GET', '/en/conference/amsterdam-2019');
         $client->submitForm('Submit', [
             'comment_form[author]' => 'Fabien',
             'comment_form[text]' => 'Some feedback from an automated functional test',
@@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
     public function testConferencePage()
     {
         $client = static::createClient();
-        $crawler = $client->request('GET', '/');
+        $crawler = $client->request('GET', '/en/');

         $this->assertCount(2, $crawler->filter('h4'));

@@ -50,6 +50,6 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertPageTitleContains('Amsterdam');
         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
-        $this->assertSelectorExists('div:contains("There are 1 comments")');
+        $this->assertSelectorExists('div:contains("There is one comment")');
     }
 }

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.