Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: improve Localization #9109

Merged
merged 13 commits into from
Aug 16, 2024
24 changes: 0 additions & 24 deletions phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -15925,30 +15925,6 @@
'count' => 2,
'path' => __DIR__ . '/tests/system/Images/ImageMagickHandlerTest.php',
];
$ignoreErrors[] = [
// identifier: method.notFound
'message' => '#^Call to an undefined method CodeIgniter\\\\Language\\\\Language\\:\\:disableIntlSupport\\(\\)\\.$#',
'count' => 1,
'path' => __DIR__ . '/tests/system/Language/LanguageTest.php',
];
$ignoreErrors[] = [
// identifier: method.notFound
'message' => '#^Call to an undefined method CodeIgniter\\\\Language\\\\Language\\:\\:loaded\\(\\)\\.$#',
'count' => 3,
'path' => __DIR__ . '/tests/system/Language/LanguageTest.php',
];
$ignoreErrors[] = [
// identifier: method.notFound
'message' => '#^Call to an undefined method CodeIgniter\\\\Language\\\\Language\\:\\:loadem\\(\\)\\.$#',
'count' => 2,
'path' => __DIR__ . '/tests/system/Language/LanguageTest.php',
];
$ignoreErrors[] = [
// identifier: method.notFound
'message' => '#^Call to an undefined method CodeIgniter\\\\Language\\\\Language\\:\\:setData\\(\\)\\.$#',
'count' => 9,
'path' => __DIR__ . '/tests/system/Language/LanguageTest.php',
];
$ignoreErrors[] = [
// identifier: missingType.iterableValue
'message' => '#^Method CodeIgniter\\\\Language\\\\LanguageTest\\:\\:provideBundleUniqueKeys\\(\\) return type has no value type specified in iterable type iterable\\.$#',
Expand Down
2 changes: 2 additions & 0 deletions system/Common.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Language\Language;
use CodeIgniter\Model;
use CodeIgniter\Session\Session;
use CodeIgniter\Test\TestLogger;
Expand Down Expand Up @@ -732,6 +733,7 @@ function is_windows(?bool $mock = null): bool
*/
function lang(string $line, array $args = [], ?string $locale = null)
{
/** @var Language $language */
$language = service('language');

// Get active locale
Expand Down
105 changes: 89 additions & 16 deletions tests/system/Language/LanguageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class LanguageTest extends CIUnitTestCase

protected function setUp(): void
{
$this->lang = new MockLanguage('en');
$this->lang = new Language('en');
}

public function testReturnsStringWithNoFileInMessage(): void
Expand All @@ -54,6 +54,8 @@ public function testReturnParsedStringWithNoFileInMessage(): void

public function testGetLineReturnsLine(): void
{
$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'bookSaved' => 'We kept the book free from the boogeyman',
'booksSaved' => 'We saved some more',
Expand All @@ -62,8 +64,67 @@ public function testGetLineReturnsLine(): void
$this->assertSame('We saved some more', $this->lang->getLine('books.booksSaved'));
}

public function testGetLineReturnsLineWithKeyWithDots(): void
{
$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'bookSaved.foo' => 'We kept the book free from the boogeyman',
'booksSaved.bar.baz' => 'We saved some more',
]);

$this->assertSame(
'We kept the book free from the boogeyman',
$this->lang->getLine('books.bookSaved.foo')
);
$this->assertSame(
'We saved some more',
$this->lang->getLine('books.booksSaved.bar.baz')
);
}

public function testGetLineCannotUseKeysWithLeadingDot(): void
{
$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'.bookSaved.foo.' => 'We kept the book free from the boogeyman',
'.booksSaved.bar.baz.' => 'We saved some more',
]);

$this->assertSame(
'books.bookSaved.foo', // Can't get the message.
$this->lang->getLine('books.bookSaved.foo')
);
$this->assertSame(
'books.booksSaved.bar.baz', // Can't get the message.
$this->lang->getLine('books.booksSaved.bar.baz')
);
}

public function testGetLineCannotUseKeysWithTrailingDot(): void
{
$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'bookSaved.foo.' => 'We kept the book free from the boogeyman',
'booksSaved.bar.baz.' => 'We saved some more',
]);

$this->assertSame(
'books.bookSaved.foo', // Can't get the message.
$this->lang->getLine('books.bookSaved.foo')
);
$this->assertSame(
'books.booksSaved.bar.baz', // Can't get the message.
$this->lang->getLine('books.booksSaved.bar.baz')
);
}

public function testGetLineReturnsFallbackLine(): void
{
$this->lang = new MockLanguage('en');

$this->lang
->setLocale('en-US')
->setData('equivalent', [
Expand All @@ -86,6 +147,8 @@ public function testGetLineReturnsFallbackLine(): void

public function testGetLineArrayReturnsLineArray(): void
{
$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'booksList' => [
'The Boogeyman',
Expand All @@ -106,6 +169,8 @@ public function testGetLineFormatsMessage(): void
$this->markTestSkipped('No intl support.');
}

$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'bookCount' => '{0, number, integer} books have been saved.',
]);
Expand All @@ -120,6 +185,8 @@ public function testGetLineArrayFormatsMessages(): void
$this->markTestSkipped('No intl support.');
}

$this->lang = new MockLanguage('en');

$this->lang->setData('books', [
'bookList' => [
'{0, number, integer} related books.',
Expand All @@ -139,6 +206,8 @@ public function testGetLineInvalidFormatMessage(): void
$this->markTestSkipped('No intl support.');
}

$this->lang = new MockLanguage('en');

$this->lang->setLocale('ar');

$line = 'تم الكشف عن كلمة المرور {0} بسبب اختراق البيانات وشوهدت {1 ، عدد} مرة في {2} في كلمات المرور المخترقة.';
Expand All @@ -163,6 +232,8 @@ public function testLangAllowsOtherLocales(): void

public function testLangDoesntFormat(): void
{
$this->lang = new MockLanguage('en');

$this->lang->disableIntlSupport();

$this->lang->setData('books', [
Expand All @@ -185,40 +256,42 @@ public function testLanguageDuplicateKey(): void

public function testLanguageFileLoading(): void
{
$this->lang = new SecondMockLanguage('en');
$lang = new SecondMockLanguage('en');

$this->lang->loadem('More', 'en');
$this->assertContains('More', $this->lang->loaded());
$lang->loadem('More', 'en');
$this->assertContains('More', $lang->loaded());

$this->lang->loadem('More', 'en');
$this->assertCount(1, $this->lang->loaded()); // should only be there once
$lang->loadem('More', 'en');
$this->assertCount(1, $lang->loaded()); // should only be there once
}

public function testLanguageFileLoadingReturns(): void
{
$this->lang = new SecondMockLanguage('en');
$lang = new SecondMockLanguage('en');

$result = $this->lang->loadem('More', 'en', true);
$this->assertNotContains('More', $this->lang->loaded());
$result = $lang->loadem('More', 'en', true);
$this->assertNotContains('More', $lang->loaded());
$this->assertCount(3, $result);

$this->lang->loadem('More', 'en');
$this->assertContains('More', $this->lang->loaded());
$this->assertCount(1, $this->lang->loaded());
$lang->loadem('More', 'en');
$this->assertContains('More', $lang->loaded());
$this->assertCount(1, $lang->loaded());
}

public function testLanguageSameKeyAndFileName(): void
{
$lang = new MockLanguage('en');

// first file data | example.message
$this->lang->setData('example', ['message' => 'This is an example message']);
$lang->setData('example', ['message' => 'This is an example message']);

// force loading data into file Example
$this->assertSame('This is an example message', $this->lang->getLine('example.message'));
$this->assertSame('This is an example message', $lang->getLine('example.message'));

// second file data | another.example
$this->lang->setData('another', ['example' => 'Another example']);
$lang->setData('another', ['example' => 'Another example']);

$this->assertSame('Another example', $this->lang->getLine('another.example'));
$this->assertSame('Another example', $lang->getLine('another.example'));
}

public function testGetLocale(): void
Expand Down
60 changes: 36 additions & 24 deletions user_guide_src/source/outgoing/localization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,6 @@ CodeIgniter provides several tools to help you localize your application for dif
localization of an application is a complex subject, it's simple to swap out strings in your application
with different supported languages.

Language strings are stored in the **app/Language** directory, with a sub-directory for each
supported language::

app/
Language/
en/
App.php
fr/
App.php

.. important:: Locale detection only works for web-based requests that use the IncomingRequest class.
Command-line requests will not have these features.

Configuring the Locale
======================

Expand All @@ -52,10 +39,13 @@ directory existed at the **app/Language/en-US** directory then that would be use
Locale Detection
================

There are two methods supported to detect the correct locale during the request. The first is a "set and forget"
method that will automatically perform :doc:`content negotiation </incoming/content_negotiation>` for you to
determine the correct locale to use. The second method allows you to specify a segment in your routes that
will be used to set the locale.
.. important:: Locale detection only works for web-based requests that use the IncomingRequest class.
Command-line requests will not have these features.

There are two methods supported to detect the correct locale during the request.

1. `Content Negotiation`_: The first is a "set and forget" method that will automatically perform :doc:`content negotiation </incoming/content_negotiation>` for you to determine the correct locale to use.
2. `In Routes`_: The second method allows you to specify a segment in your routes that will be used to set the locale.

Should you ever need to set the locale directly, see `Setting the Current Locale`_.

Expand Down Expand Up @@ -139,6 +129,16 @@ Language Localization
Creating Language Files
=======================

Language strings are stored in the **app/Language** directory, with a sub-directory for each
supported language (locale)::

app/
Language/
en/
App.php
fr/
App.php

.. note:: The Language Files do not have namespaces.

Languages do not have any specific naming convention that are required. The file should be named logically to
Expand All @@ -149,6 +149,8 @@ Within the file, you would return an array, where each element in the array has

.. literalinclude:: localization/007.php

.. note:: You cannot use dots (``.``) at the beginning and end of language keys.

It also support nested definition:

.. literalinclude:: localization/008.php
Expand All @@ -170,7 +172,7 @@ For nested definition, you would do the following:

.. literalinclude:: localization/011.php

If the requested language key doesn't exist in the file for the current locale, the string will be passed
If the requested language key doesn't exist in the file for the current locale (after `Language Fallback`_), the string will be passed
back, unchanged. In this example, it would return ``Errors.errorEmailMissing`` or ``Errors.nested.error.message`` if it didn't exist.

Replacing Parameters
Expand Down Expand Up @@ -231,25 +233,25 @@ Language Fallback
=================

If you have a set of messages for a given locale, for instance
**Language/en/app.php**, you can add language variants for that locale,
each in its own folder, for instance **Language/en-US/app.php**.
**Language/en/App.php**, you can add language variants for that locale,
each in its own folder, for instance **Language/en-US/App.php**.

You only need to provide values for those messages that would be
localized differently for that locale variant. Any missing message
definitions will be automatically pulled from the main locale settings.

It gets better - the localization can fall all the way back to English,
It gets better - the localization can fall all the way back to English (**en**),
in case new messages are added to the framework and you haven't had
a chance to translate them yet for your locale.

So, if you are using the locale ``fr-CA``, then a localized
message will first be sought in the **Language/fr-CA** directory, then in
the **Language/fr** directory, and finally in the **Language/en** directory.

Message Translations
====================
System Message Translations
===========================

We have an "official" set of translations in their
We have an "official" set of the system message translations in their
`own repository <https://github.com/codeigniter4/translations>`_.

You could download that repository, and copy its **Language** folder
Expand All @@ -266,6 +268,16 @@ project:
The translated messages will be automatically picked
up because the translations folders get mapped appropriately.

Overriding System Message Translations
======================================

The framework provide `System Message Translations`_, and packages that you
installed may also provide the message translations.

If you want to override some language messages, create language files in the
**app/Language** directory. Then, return only the array you want to override
in the file.

.. _generating-translation-files-via-command:

Generating Translation Files via Command
Expand Down
Loading