Локализация приложения
Аудитория Symfony распределена по всему миру и благодаря этому, интернационализация (i18n) и локализация (l10n) уже очень давно доступны из коробки. Локализация приложения заключается не только в переводе интерфейса, но и в обработке форм множественного числа, форматировании даты и валюты, 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
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
) {
}
- #[Route('/', name: 'homepage')]
+ #[Route('/{_locale}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
return $this->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
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
) {
}
- #[Route('/{_locale}/', name: 'homepage')]
+ #[Route('/{_locale<en|fr>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
return $this->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
@@ -9,6 +9,7 @@ parameters:
admin_email: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"
default_base_url: 'http://127.0.0.1'
router.request_context.base_url: '%env(default:default_base_url:SYMFONY_DEFAULT_ROUTE_URL)%'
+ app.supported_locales: 'en|fr'
services:
# default configuration for services in *this* file
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
) {
}
- #[Route('/{_locale<en|fr>}/', name: 'homepage')]
+ #[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
return $this->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
@@ -35,7 +35,7 @@ class ConferenceController extends AbstractController
])->setSharedMaxAge(3600);
}
- #[Route('/conference_header', name: 'conference_header')]
+ #[Route('/{_locale<%app.supported_locales%>}/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
{
return $this->render('conference/header.html.twig', [
@@ -43,7 +43,7 @@ class ConferenceController extends AbstractController
])->setSharedMaxAge(3600);
}
- #[Route('/conference/{slug}', name: 'conference')]
+ #[Route('/{_locale<%app.supported_locales%>}/conference/{slug}', name: 'conference')]
public function show(
Request $request,
Conference $conference,
Мы почти закончили. У нас больше нет маршрута, который бы совпадал с /
. Давайте вернём его и сделаем так, чтобы он перенаправлял на /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
@@ -27,6 +27,12 @@ class ConferenceController extends AbstractController
) {
}
+ #[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
.
В зависимости от локали, её имя не всегда пишется с большой буквы. Для правильного написания заглавных букв в предложениях нам нужен фильтр, который поддерживает Юникод. К счастью, такой Twig-фильтр уже реализован, благодаря компоненту Symfony String:
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 автоматически отображает переведённые метки форм. Перейдите на страницу конференции и нажмите на вкладку с переводами на панели отладки, чтобы увидеть все метки, у которых отсутствует перевод:
Локализация дат
Если переключится на французский язык и перейти на какую-нибудь страницу конференции, у которой есть комментарии, вы заметите, что даты комментариев автоматически локализованы. Это сработало потому, что мы использовали 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[author]' => 'Fabien',
'comment[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")');
}
}