Skip to content

Локалізація застосунку

Завдяки міжнародній аудиторії, Symfony, як ніколи раніше, може обробляти інтернаціоналізацію (i18n) і локалізацію (l10n) з коробки. Локалізація застосунку — це не тільки переклад інтерфейсу, але й множини, форматування дати й валюти, URL-адрес тощо.

Інтернаціоналізація URL-адрес

Першим кроком до інтернаціоналізації веб-сайту є інтернаціоналізація URL-адрес. При перекладі інтерфейсу веб-сайту URL-адреса має відрізнятися залежно від локалі, щоб ладнати з кешами HTTP (ніколи не використовуйте ту саму URL-адресу й не зберігайте локаль у сесії).

Використовуйте спеціальний параметр маршруту _locale, щоб посилатися на локаль у маршрутах:

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', [

На головній сторінці локаль тепер встановлюється зсередини залежно від URL-адреси; наприклад, якщо ви перейдете до /fr/$request->getLocale() повертає fr.

Оскільки ви, ймовірно, не зможете перекласти вміст у всіх допустимих локалях, обмежтеся тими, які ви хочете підтримувати:

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', [

Кожен параметр маршруту може бути обмежений регулярним виразом всередині < >. Маршрут homepage зараз збігається тільки тоді, коли параметром _locale є en чи fr. Спробуйте перейти до /es/, ви маєте отримати 404, оскільки жоден маршрут не збігається.

Оскільки ми будемо використовувати ту саму вимогу майже у всіх маршрутах, перемістімо її в параметр контейнера:

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', [

Додавання мови можна здійснити шляхом оновлення параметру app.supported_languages.

Додайте той самий префікс маршруту локалі до інших 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();

Ми вже майже завершили. У нас більше немає маршруту, який збігається з /. Додаймо його назад і зробімо перенаправлення на /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
     {

Тепер, коли всі основні маршрути враховують особливості локалі, зверніть увагу, що створені URL-адреси на сторінках автоматично враховують поточну локаль.

Додавання перемикача локалі

Щоб дозволити користувачам перемикатися з локалі за замовчуванням en на іншу, додаймо перемикач у шапку:

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>

Для перемикання на іншу локаль ми явно передаємо параметр маршруту _locale у функцію path().

Оновіть шаблон, щоб відобразити ім'я поточної локалі замість жорстко закодованого "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 — це глобальна змінна Twig, яка дає доступ до поточного запиту. Щоб перетворити локаль у читабельний рядок, ми використовуємо фільтр Twig locale_name.

Залежно від локалі, ім'я локалі не завжди пишеться з великої літери. Щоб належним чином прописувати фрази, нам потрібен фільтр, який враховує Unicode, як це передбачено компонентом Symfony String і його реалізацією Twig:

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>

Тепер ви можете перемикатися з французької на англійську за допомогою перемикача і весь інтерфейс досить добре адаптується:

/fr/conference/amsterdam-2019

Переклад інтерфейсу

Переклад кожної окремої фрази на великому веб-сайті може бути виснажливим, але, на щастя, на нашому веб-сайті є тільки кілька повідомлень. Почнімо з усіх фраз на головній сторінці:

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>

Фільтр Twig trans шукає переклад даного значення в поточній локалі. Якщо його не знайдено, він повертає значення локалі за замовчуванням, як це налаштовано в config/packages/translation.yaml:

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

Зверніть увагу, що "вкладка" перекладу на панелі інструментів веб-налагодження стала червоною:

/fr/

Це говорить нам про те, що 3 повідомлення ще не перекладені.

Натисніть на "вкладку", щоб вивести список всіх повідомлень, для яких Symfony не знайшов перекладу:

/_profiler/64282d?panel=translation

Надання перекладів

Як ви могли бачити в config/packages/translation.yaml, переклади зберігаються в кореневому каталозі translations/, який було створено для нас автоматично.

Замість того щоб створювати файли перекладу вручну, використовуйте команду translation:extract:

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

Ця команда генерує файл перекладу (прапорець --force) для локалі fr і домену messages. Домен messages (містить всі повідомлення застосунку, за винятком тих, які надходять від самого Symfony, наприклад, помилки валідації чи безпеки.

Відредагуйте файл 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
--- 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>

Зверніть увагу, що ми не будемо перекладати всі шаблони, але не соромтеся робити це:

/fr/

Переклад форм

Мітки форм автоматично відображаються Symfony, за допомогою системи перекладу. Перейдіть на сторінку конференції й натисніть на вкладку "Translation" на панелі інструментів веб-налагодження; ви маєте побачити всі мітки, що готові до перекладу:

/_profiler/64282d?panel=translation

Локалізація дат

Якщо ви перемкнете на французьку мову й перейдете на веб-сторінку конференції, що містить деякі коментарі — ви помітите, що дати коментарів автоматично локалізуються. Це працює, тому що ми використовували фільтр Twig format_datetime, який враховує особливості локалі ({{ comment.createdAt|format_datetime('medium', 'short') }}).

Локалізація працює для дат, часу (format_time), валют (format_currency) і чисел (format_number) загалом (відсотки, тривалість, пропис, ...).

Переклад множини

Управління множинами в перекладах є одним із основних джерел більш загальної проблеми вибору перекладу на основі умови.

На сторінці конференції ми відображаємо кількість коментарів: There are 2 comments. Для 1 коментаря ми відображаємо There are 1 comments, що неправильно. Змініть шаблон для перетворення речення в повідомлення, що перекладається:

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 %}

Для цього повідомлення ми використовували іншу стратегію перекладу. Замість того щоб зберегти англійську версію в шаблоні, ми замінили її унікальним ідентифікатором. Ця стратегія краще працює для складних і великих обсягів тексту.

Оновіть файл перекладу, додавши нове повідомлення:

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>

Ми ще не закінчили, оскільки тепер нам потрібно надати переклад на англійську мову. Створіть файл 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>

Оновлення функціональних тестів

Не забудьте оновити функціональні тести, щоб врахувати зміни URL-адрес і змісту:

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