Локалізація застосунку
Завдяки міжнародній аудиторії, 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>
Тепер ви можете перемикатися з французької на англійську за допомогою перемикача і весь інтерфейс досить добре адаптується:
Переклад інтерфейсу
Переклад кожної окремої фрази на великому веб-сайті може бути виснажливим, але, на щастя, на нашому веб-сайті є тільки кілька повідомлень. Почнімо з усіх фраз на головній сторінці:
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') }}">
- 📙 Conference Guestbook
+ 📙 {{ '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
Зверніть увагу, що "вкладка" перекладу на панелі інструментів веб-налагодження стала червоною:
Це говорить нам про те, що 3 повідомлення ще не перекладені.
Натисніть на "вкладку", щоб вивести список всіх повідомлень, для яких Symfony не знайшов перекладу:
Надання перекладів
Як ви могли бачити в 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>
Зверніть увагу, що ми не будемо перекладати всі шаблони, але не соромтеся робити це:
Переклад форм
Мітки форм автоматично відображаються Symfony, за допомогою системи перекладу. Перейдіть на сторінку конференції й натисніть на вкладку "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
:
Оновлення функціональних тестів
Не забудьте оновити функціональні тести, щоб врахувати зміни 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")');
}
}