diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f942d1..62e678e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md index b3a46fa..5c063af 100644 --- a/README.md +++ b/README.md @@ -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 + '{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 +=7.4" + "php": ">=8.3", + "ext-intl": "*" }, "require-dev": { "phpunit/phpunit": "^9.3", diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php index 7b1cf73..8f27a22 100644 --- a/src/Locale/Locale.php +++ b/src/Locale/Locale.php @@ -13,6 +13,13 @@ class Locale */ protected static $language = []; + /** + * Locale whose plural rules apply to each language + * + * @var array + */ + protected static $rules = []; + /** * Throw Exceptions? * @@ -50,10 +57,12 @@ public static function getLanguages(): array * * @param string $name * @param array $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; } /** @@ -61,8 +70,9 @@ public static function setLanguageFromArray(string $name, array $translations): * * @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.'); @@ -71,6 +81,7 @@ public static function setLanguageFromJSON(string $name, string $path): void /** @var array $translations */ $translations = json_decode(file_get_contents($path) ?: '', true); self::$language[$name] = $translations; + self::$rules[$name] = $rules ?? $name; } public function __construct(string $default) @@ -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 $placeholders * @param string|null $default + * @param array $placeholders + * @param array $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) { @@ -151,6 +168,10 @@ 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); } @@ -158,6 +179,65 @@ public function getText(string $key, string|null $default = self::DEFAULT_DYNAMI 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 $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 $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 * diff --git a/tests/Locale/LocaleTest.php b/tests/Locale/LocaleTest.php index cef251e..7da50bf 100755 --- a/tests/Locale/LocaleTest.php +++ b/tests/Locale/LocaleTest.php @@ -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 @@ -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'); diff --git a/tests/en-plurals.json b/tests/en-plurals.json new file mode 100644 index 0000000..c482ebf --- /dev/null +++ b/tests/en-plurals.json @@ -0,0 +1,3 @@ +{ + "minutes": "{count, plural, one {in # minute} other {in # minutes}}" +}