Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ jobs:
- name: Run Tests
run: |
docker run --rm --interactive -v $PWD:/app composer sh -c \
"composer install --profile --ignore-platform-reqs && composer test"
"install-php-extensions intl && composer install --profile --ignore-platform-reqs && composer test"

43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ $locale->setDefault('he-IL');
echo $locale->getText('hello'); // prints "שלום"
```

### Plurals

Plural translations are [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) patterns with a `count` argument, formatted with the [CLDR plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) of the language that has the translation. Inside a pattern, `{` and `}` are ICU syntax, so keep `{{placeholders}}` in the sentence around it and pass any other value as an ICU argument instead.

```php
<?php

Locale::setLanguageFromArray('en-US', [
'minutes' => '{count, plural, one {in # minute} other {in # minutes}}',
'expire' => 'This code will expire {{expire}}.',
'invites' => '{count, plural, one {# invite left for {team}} other {# invites left for {team}}}',
]);
Locale::setLanguageFromArray('ru-RU', [
'minutes' => '{count, plural, one {через # минуту} few {через # минуты} many {через # минут} other {через # минуты}}',
]);

$locale = new Locale('ru-RU');
$locale->setFallback('en-US');

echo $locale->getPlural('minutes', 5); // prints "через 5 минут"

// Plural placeholders use the language of the translation they fill, here the en-US fallback
echo $locale->getText('expire', plurals: ['expire' => ['minutes', 21]]); // prints "This code will expire in 21 minutes."

// Patterns can take other ICU arguments
echo $locale->getPlural('invites', 2, arguments: ['team' => 'Appwrite']); // prints "2 invites left for Appwrite"
```

A language that has no translation for the plural key, or whose pattern does not compile, falls back to the fallback language and its rules. `selectordinal` and the other ICU types work the same way.

When a language loads another language's translations, pass the locale whose plural rules apply:

```php
<?php

Locale::setLanguageFromJSON('sr-RS', 'path/to/en.json', 'en');
```

## Expected Structure of Translations

Each translation is a **key-value** pair. The **key** is an identifier that represents a string in your app. The value is the translation in the specified locale.
Expand Down Expand Up @@ -79,7 +117,7 @@ When using `setLanguageFromJSON($code, $path)` for the `en-US` locale you need t

## System Requirements

Utopia Framework requires PHP 7.4 or later. We recommend using the latest PHP version whenever possible.
Utopia Framework requires PHP 8.3 or later and the `intl` extension. We recommend using the latest PHP version whenever possible.

## Tests

Expand All @@ -94,7 +132,8 @@ docker run --rm --interactive --tty \
Finally, you can run the tests:

```shell
docker run --rm -v $(pwd):$(pwd):rw -w $(pwd) php:7.4-cli-alpine sh -c "vendor/bin/phpunit tests/Locale/LocaleTest.php"
docker run --rm --interactive --volume $PWD:/app composer sh -c \
"install-php-extensions intl && composer test"
```

## Copyright and license
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"psr-4": {"Utopia\\Locale\\": "src/Locale"}
},
"require": {
"php": ">=7.4"
"php": ">=8.3",
"ext-intl": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.3",
Expand Down
88 changes: 84 additions & 4 deletions src/Locale/Locale.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ class Locale
*/
protected static $language = [];

/**
* Locale whose plural rules apply to each language
*
* @var array<string, string>
*/
protected static $rules = [];

/**
* Throw Exceptions?
*
Expand Down Expand Up @@ -50,19 +57,22 @@ public static function getLanguages(): array
*
* @param string $name
* @param array<string, string> $translations
* @param string|null $rules Locale whose plural rules apply, defaults to $name
*/
public static function setLanguageFromArray(string $name, array $translations): void //TODO add support for lazy load to memory
public static function setLanguageFromArray(string $name, array $translations, ?string $rules = null): void //TODO add support for lazy load to memory
{
self::$language[$name] = $translations;
self::$rules[$name] = $rules ?? $name;
}

/**
* Set New Locale from JSON file
*
* @param string $name
* @param string $path
* @param string|null $rules Locale whose plural rules apply, defaults to $name
*/
public static function setLanguageFromJSON(string $name, string $path): void
public static function setLanguageFromJSON(string $name, string $path, ?string $rules = null): void
{
if (! file_exists($path) && self::$exceptions) {
throw new Exception('Translation file not found.');
Expand All @@ -71,6 +81,7 @@ public static function setLanguageFromJSON(string $name, string $path): void
/** @var array<string, string> $translations */
$translations = json_decode(file_get_contents($path) ?: '', true);
self::$language[$name] = $translations;
self::$rules[$name] = $rules ?? $name;
}

public function __construct(string $default)
Expand Down Expand Up @@ -121,26 +132,32 @@ public function setDefault(string $name): self
/**
* Get Text by Locale
*
* Plural placeholders are formatted in the same language as the translation they fill.
*
* @param string $key
* @param array<string, string|int> $placeholders
* @param string|null $default
* @param array<string, string|int> $placeholders
* @param array<string, array{string, int|float}> $plurals Placeholder name => [plural key, count]
* @return mixed
*
* @throws Exception
*/
public function getText(string $key, string|null $default = self::DEFAULT_DYNAMIC_KEY, array $placeholders = [])
public function getText(string $key, string|null $default = self::DEFAULT_DYNAMIC_KEY, array $placeholders = [], array $plurals = [])
{
$defaultExists = \array_key_exists($key, self::$language[$this->default]);
$fallbackExists = \array_key_exists($key, self::$language[$this->fallback ?? ''] ?? []);

$translation = $default === self::DEFAULT_DYNAMIC_KEY ? '{{'.$key.'}}' : $default;
$language = $this->default;

if ($fallbackExists) {
$translation = self::$language[$this->fallback ?? ''][$key];
$language = $this->fallback ?? '';
}

if ($defaultExists) {
$translation = self::$language[$this->default][$key];
$language = $this->default;
}

if (! $defaultExists && ! $fallbackExists && self::$exceptions) {
Expand All @@ -151,13 +168,76 @@ public function getText(string $key, string|null $default = self::DEFAULT_DYNAMI
return null;
}

foreach ($plurals as $placeholderKey => [$pluralKey, $count]) {
$placeholders[$placeholderKey] = $this->format($language, $pluralKey, $count) ?? '{{'.$pluralKey.'}}';
}

foreach ($placeholders as $placeholderKey => $placeholderValue) {
$translation = str_replace('{{'.$placeholderKey.'}}', (string) $placeholderValue, $translation);
}

return $translation;
}

/**
* Get plural text by Locale
*
* The translation is an ICU MessageFormat pattern with a `count` argument, for example
* `{count, plural, one {# minute} other {# minutes}}`.
*
* @param string $key
* @param int|float $count
* @param string|null $default
* @param array<string, string|int|float> $arguments Other ICU arguments of the pattern, written as `{name}`
*
* @throws Exception
*/
public function getPlural(string $key, int|float $count, string|null $default = self::DEFAULT_DYNAMIC_KEY, array $arguments = []): ?string
{
return $this->format($this->default, $key, $count, $arguments) ?? ($default === self::DEFAULT_DYNAMIC_KEY ? '{{'.$key.'}}' : $default);
}

/**
* Format a plural translation with the rules of the language that has it, trying the fallback language next
*
* @param string $language
* @param string $key
* @param int|float $count
* @param array<string, string|int|float> $arguments
*
* @throws Exception
*/
protected function format(string $language, string $key, int|float $count, array $arguments = []): ?string
{
$invalid = null;

foreach (\array_unique(\array_filter([$language, $this->fallback])) as $name) {
$pattern = self::$language[$name][$key] ?? null;

if (! \is_string($pattern)) {
continue;
}

try {
$text = (new \MessageFormatter(self::$rules[$name] ?? $name, $pattern))->format(['count' => $count] + $arguments);
} catch (\IntlException $exception) {
$invalid ??= 'Key named "'.$key.'" in "'.$name.'" is not a valid plural pattern: '.$exception->getMessage();

continue;
}

if (\is_string($text)) {
return $text;
}
}

if (self::$exceptions) {
throw new Exception($invalid ?? 'Key named "'.$key.'" not found');
}

return null;
}

/**
* Get list of configured transltions in specific language
*
Expand Down
112 changes: 111 additions & 1 deletion tests/Locale/LocaleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,28 @@ public function setUp(): void

Locale::setLanguageFromJSON('hi-IN', realpath(__DIR__.'/../hi-IN.json') ?: ''); // Set Hindi

$this->assertCount(3, Locale::getLanguages());
// Plural translations are ICU MessageFormat patterns with a `count` argument
Locale::setLanguageFromArray('en-GB', [
'minutes' => '{count, plural, one {in # minute} other {in # minutes}}',
'categories' => '{count, plural, zero {zero} one {one} two {two} few {few} many {many} other {other}}',
'broken' => '{count, plural, one {in # minute} other {in # minutes}}',
'expires' => 'This code expires {{expire}}.',
'invites' => '{count, plural, one {# invite left for {team}} other {# invites left for {team}}}',
]);

Locale::setLanguageFromArray('ru-RU', [
'minutes' => '{count, plural, one {через # минуту} few {через # минуты} many {через # минут} other {через # минуты}}',
'expires' => 'Код истекает {{expire}}.',
'broken' => '{count, plural, one {через # минуту}',
]);

Locale::setLanguageFromArray('cs-CZ', ['minutes' => '{count, plural, one {# minuta} few {# minuty} other {# minut}}']);
Locale::setLanguageFromArray('ja-JP', ['minutes' => '{count, plural, other {#分}}']);
Locale::setLanguageFromArray('ar-AE', ['categories' => '{count, plural, zero {zero} one {one} two {two} few {few} many {many} other {other}}']);

// Serbian reading English translations, with and without English plural rules
Locale::setLanguageFromJSON('sr-RS', realpath(__DIR__.'/../en-plurals.json') ?: '', 'en');
Locale::setLanguageFromJSON('sr-Latn-RS', realpath(__DIR__.'/../en-plurals.json') ?: '');
}

public function tearDown(): void
Expand Down Expand Up @@ -121,6 +142,95 @@ public function testFallback(): void
}
}

public function testPlurals(): void
{
$locale = new Locale('ru-RU');

$this->assertEquals('через 1 минуту', $locale->getPlural('minutes', 1));
$this->assertEquals('через 2 минуты', $locale->getPlural('minutes', 2));
$this->assertEquals('через 5 минут', $locale->getPlural('minutes', 5));
$this->assertEquals('через 21 минуту', $locale->getPlural('minutes', 21));

$locale->setDefault('cs-CZ');

$this->assertEquals('1 minuta', $locale->getPlural('minutes', 1));
$this->assertEquals('3 minuty', $locale->getPlural('minutes', 3));
$this->assertEquals('5 minut', $locale->getPlural('minutes', 5));

$locale->setDefault('ja-JP');

$this->assertEquals('1分', $locale->getPlural('minutes', 1));
$this->assertEquals('5分', $locale->getPlural('minutes', 5));

// Arabic uses every plural category
$locale->setDefault('ar-AE');

$this->assertEquals('zero', $locale->getPlural('categories', 0));
$this->assertEquals('one', $locale->getPlural('categories', 1));
$this->assertEquals('two', $locale->getPlural('categories', 2));
$this->assertEquals('few', $locale->getPlural('categories', 3));
$this->assertEquals('many', $locale->getPlural('categories', 11));
$this->assertEquals('other', $locale->getPlural('categories', 100));

// Fractions have their own category
$locale->setDefault('en-GB');

$this->assertEquals('one', $locale->getPlural('categories', 1));
$this->assertEquals('other', $locale->getPlural('categories', 1.5));

$this->assertEquals('2 invites left for Appwrite', $locale->getPlural('invites', 2, arguments: ['team' => 'Appwrite']));
}

public function testPluralRules(): void
{
// Serbian puts 21 in the `one` category, English does not
$this->assertEquals('in 21 minutes', (new Locale('sr-RS'))->getPlural('minutes', 21));
$this->assertEquals('in 21 minute', (new Locale('sr-Latn-RS'))->getPlural('minutes', 21));
}

public function testPluralFallback(): void
{
$locale = new Locale('ru-RU');
$locale->setFallback('en-GB');

$this->assertEquals('Код истекает через 21 минуту.', $locale->getText('expires', plurals: ['expire' => ['minutes', 21]]));

// A pattern that does not compile falls back to the fallback language
$this->assertEquals('in 5 minutes', $locale->getPlural('broken', 5));

// A translation served by the fallback language is filled with that language's plural
$locale->setDefault('cs-CZ');

$this->assertEquals('This code expires in 21 minutes.', $locale->getText('expires', plurals: ['expire' => ['minutes', 21]]));
}

public function testGetPluralDefault(): void
{
$locale = new Locale('en-GB');

$this->assertEquals('in 5 minutes', $locale->getPlural('minutes', 5));
$this->assertEquals('{{missing}}', $locale->getPlural('missing', 5));
$this->assertEquals('soon', $locale->getPlural('missing', 5, default: 'soon'));
$this->assertEquals(null, $locale->getPlural('missing', 5, default: null));
$this->assertEquals('This code expires {{missing}}.', $locale->getText('expires', plurals: ['expire' => ['missing', 5]]));

Locale::$exceptions = true;

try {
$locale->getPlural('missing', 5);
$this->fail('Failed to throw exception when translation is missing');
} catch (Exception $e) {
$this->assertEquals('Key named "missing" not found', $e->getMessage());
}

try {
(new Locale('ru-RU'))->getPlural('broken', 5);
$this->fail('Failed to throw exception when the pattern is invalid');
} catch (Exception $e) {
$this->assertStringContainsString('is not a valid plural pattern', $e->getMessage());
}
}

public function testGetTextDefault(): void
{
$locale = new Locale('en-US');
Expand Down
3 changes: 3 additions & 0 deletions tests/en-plurals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"minutes": "{count, plural, one {in # minute} other {in # minutes}}"
}
Loading