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:
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') }}">
- 📙 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>
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:
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:
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>
Zauważ, że nie przetłumaczyłem wszystkich szablonów, ale możesz przejąć pałeczkę:
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:
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
:
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")');
}
}