Skip to content

Lokalizacja aplikacji

Dzięki użytkownikom z całego świata, Symfony od początku istnienia jest w stanie poradzić sobie z internacjonalizacją (i18n) i lokalizacją (l10n) od razu po zainstalowaniu frameworka. Lokalizacja aplikacji to nie tylko tłumaczenie interfejsu, ale także uwzględnienie liczby mnogiej tłumaczonych rzeczowników, formatowania dat i walut, adresów URL i wiele więcej.

Internacjonalizacja adresów URL

Pierwszym krokiem do internacjonalizacji strony internetowej jest internacjonalizacja adresów URL. Podczas tłumaczenia interfejsu strony internetowej, adres URL powinien być inny dla każdego miejsca, aby współgrał z pamięcią podręczną HTTP (nigdy nie przechowuj adresu URL razem z kodem języka w sesji).

Użyj specjalnego parametru trasy (ang. route), _locale, aby odnieść się do lokalizacji w trasach:

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

-    #[Route('/', name: 'homepage')]
+    #[Route('/{_locale}/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {
         $response = new Response($this->twig->render('conference/index.html.twig', [

Kod języka (ang. locale) na stronie głównej jest teraz ustawiany wewnętrznie w zależności od adresu URL; na przykład, jeśli trafisz /fr/, $request->getLocale() zwraca fr.

Ponieważ prawdopodobnie nie będziesz w stanie przetłumaczyć treści na wszystkie dostępne języki, ogranicz się do tych, które chcesz obsługiwać:

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

-    #[Route('/{_locale}/', name: 'homepage')]
+    #[Route('/{_locale<en|fr>}/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {
         $response = new Response($this->twig->render('conference/index.html.twig', [

Każdy parametr trasy może być ograniczony wyrażeniem regularnym wewnątrz < oraz >. Trasa homepage pasuje tylko wtedy, gdy parametr _locale jest ustawiony na en lub fr. Podaj URL /es/, a dostaniesz błąd 404, ponieważ żadna trasa nie została dopasowana do wyrażenia regularnego.

Ponieważ na prawie wszystkich trasach będziemy stosować ten sam wymóg, przenieśmy je do parametru kontenera:

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: admin@example.com
     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_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
         $this->bus = $bus;
     }

-    #[Route('/{_locale<en|fr>}/', name: 'homepage')]
+    #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {
         $response = new Response($this->twig->render('conference/index.html.twig', [

Język można dodać poprzez modyfikację parametru app.supported_languages.

Dodaj ten sam prefiks trasy dla danego kraju do innych adresów URL:

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
@@ -44,7 +44,7 @@ class ConferenceController extends AbstractController
         return $response;
     }

-    #[Route('/conference_header', name: 'conference_header')]
+    #[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
     public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
     {
         $response = new Response($this->twig->render('conference/header.html.twig', [
@@ -55,7 +55,7 @@ class ConferenceController extends AbstractController
         return $response;
     }

-    #[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): Response
     {
         $comment = new Comment();

Prawie skończyliśmy. Nie mamy już trasy, która pasowałaby do /. Dodajmy ją z powrotem i przekierujmy na /en/:

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

+    #[Route('/')]
+    public function indexNoLocale(): Response
+    {
+        return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+    }
+
     #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {

Teraz, gdy wszystkie główne trasy biorą pod uwagę obecne ustawienia regionalne, należy zauważyć, że wygenerowane adresy URL na stronach automatycznie uwzględniają kod języka.

Dodawanie przełącznika ustawień regionalnych (ang. locale)

Aby umożliwić użytkownikom przejście z domyślnego kodu języka (ang. locale) en na inny, dodajmy przełącznik w nagłówku:

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-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        English
+    </a>
+    <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
+        <li><a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a></li>
+        <li><a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'}) }}">Français</a></li>
+    </ul>
+</li>
                         </ul>
                     </div>
                 </div>

Aby przełączyć się na inny kod języka (ang. locale), przekazujemy parametr _locale trasy do funkcji path().

Zaktualizuj szablon, aby wyświetlić aktualną nazwę języka zamiast zakodowanego na sztywno "English":

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-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        English
+        {{ app.request.locale|locale_name(app.request.locale) }}
     </a>
     <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
         <li><a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a></li>

app jest globalną zmienną biblioteki Twig, która umożliwia dostęp do bieżącego żądania (ang. request). Aby przekonwertować nazwę obecnej lokalizacji do formy czytelnej dla użytkownika, używamy filtra locale_name.

W zależności od języka, nazwa nie zawsze jest zapisana wielkimi literami. Do poprawnej zmiany wielkości liter w zdaniu potrzebny jest filtr, który uwzględnia Unicode. Zapewnia go komponent Symfony String i jego implementacja w Twigu:

1
$ symfony composer req twig/string-extra
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-bs-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>
     <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language">
         <li><a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'}) }}">English</a></li>

Teraz można zmienić język z francuskiego na angielski za pomocą przełącznika, a cały interfejs ładnie się dostosowuje:

/fr/conference/amsterdam-2019

Tłumaczenie interfejsu

Tłumaczenie każdego zdania na dużej stronie internetowej może być żmudne, ale na szczęście u nas jest tylko kilka wiadomości. Zacznijmy od wszystkich zdań na stronie głównej:

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 me-4 pr-2" href="{{ path('homepage') }}">
-                        &#128217; Conference Guestbook
+                        &#128217; {{ 'Conference Guestbook'|trans }}
                     </a>

                     <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#header-menu" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Show/Hide 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-primary stretched-link">
-                                View
+                                {{ 'View'|trans }}
                             </a>
                         </div>
                     </div>

Filtr trans biblioteki Twig wyszukuje tłumaczenie podanego tekstu na bieżącą lokalizację. Jeśli nie zostanie znalezione, powróci do wartości z domyślnej lokalizacji skonfigurowanej w config/packages/translation.yaml:

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

Zauważ, że na pasku narzędzi do debugowania zakładka tłumaczeń zmieniła kolor na czerwony:

/fr/

To jest sygnał, że trzy wiadomości nie zostały jeszcze przetłumaczone.

Kliknij na zakładkę, aby wyświetlić listę wszystkich wiadomości, dla których Symfony nie znalazł tłumaczenia:

/_profiler/64282d?panel=translation

Dostarczanie tłumaczeń

Jak można było zauważyć w config/packages/translation.yaml, tłumaczenia są przechowywane w katalogu translations/, który został utworzony automatycznie.

Zamiast tworzyć pliki tłumaczeń ręcznie, użyj polecenia translation:extract:

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

Polecenie to generuje plik tłumaczeń (flaga --force) dla kodu języka fr i domeny messages, która zawiera wszystkie wiadomości związane z aplikacją, ale bez tych, które dostarcza Symfony – takich jak związane z walidacją lub błędami bezpieczeństwa.

Edytuj plik translations/messages+intl-icu.fr.xlf i przetłumacz zawarte w nim teksty na język francuski. Nie mówisz po francusku? Pozwól, że Ci pomogę:

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="eOy4.6V" resname="Conference Guestbook">
         <source>Conference Guestbook</source>
-        <target>__Conference Guestbook</target>
+        <target>Livre d'Or pour Conferences</target>
       </trans-unit>
       <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>
     </body>
   </file>
translations/messages+intl-icu.fr.xlf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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="fr" datatype="plaintext" original="file.ext">
    <header>
    <tool tool-id="symfony" tool-name="Symfony" />
    </header>
    <body>
    <trans-unit id="LNAVleg" resname="Give your feedback!">
        <source>Give your feedback!</source>
        <target>Donnez votre avis !</target>
    </trans-unit>
    <trans-unit id="3Mg5pAF" resname="View">
        <source>View</source>
        <target>Sélectionner</target>
    </trans-unit>
    <trans-unit id="eOy4.6V" resname="Conference Guestbook">
        <source>Conference Guestbook</source>
        <target>Livre d'Or pour Conferences</target>
    </trans-unit>
    </body>
</file>
</xliff>

Zauważ, że nie przetłumaczyłem wszystkich szablonów, ale możesz przejąć pałeczkę:

/fr/

Tłumaczenie formularzy

Etykiety formularzy są automatycznie wyświetlane przez Symfony poprzez system tłumaczeń. Przejdź na stronę konferencji i kliknij na zakładkę "Tłumaczenie" na pasku narzędzi do debugowania; wszystkie etykiety powinny być gotowe do tłumaczenia:

/_profiler/64282d?panel=translation

Ustawienia regionalne dat

Jeżeli przełączysz się na francuski i wejdziesz na stronę konferencji, na której są już jakieś komentarze, zauważysz, że daty tych komentarzy są automatycznie dostosowane do ustawień regionalnych użytkownika. Ten mechanizm działa dzięki dostępnemu w Twigu filtrowi format_datetime, który bierze pod uwagę ustawienia regionalne ({{ comment.createdAt|format_datetime('medium', 'short') }}).

Lokalizacja działa dla dat, czasu (format_time), walut (format_currency) i liczb (format_number) w wielu formach (procenty, czas trwania, zapis słowny i wiele innych).

Tłumaczenie liczby mnogiej

Zarządzanie liczbą mnogą w tłumaczeniach jest jednym z przypadków bardziej ogólnego problemu wyboru tłumaczenia w oparciu o warunki.

Na stronie konferencji wyświetlana jest liczba komentarzy: There are 2 comments. Dla jednego komentarza wyświetlamy There are 1 comments, co jest błędne. Zmodyfikuj szablon, aby przekonwertować zdanie na komunikat do tłumaczenia:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -44,7 +44,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 %}

W tym celu zastosowaliśmy inną strategię tłumaczenia. Zamiast zachować wersję angielską w szablonie, zastąpiliśmy ją unikalnym identyfikatorem. Ta strategia lepiej sprawdza się w przypadku tekstów skomplikowanych i długich.

Zaktualizuj plik tłumaczenia poprzez dodanie nowej wiadomości:

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>Conference Guestbook</source>
         <target>Livre d'Or pour Conferences</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>

Jeszcze nie skończyliśmy, ponieważ musimy teraz zadbać o tłumaczenie na język angielski. Utwórz pliktranslations/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>

Aktualizowanie testów funkcjonalnych

Nie zapomnij zaktualizować testów funkcjonalnych, aby brały pod uwagę zmiany adresów URL i treści:

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.
TOC
    Version