From 87472f1af2e5d9a9b0b962a78729d408d1cb3294 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 17 Sep 2026 18:17:23 +0530 Subject: [PATCH 1/3] feat: add plural translations Format ICU MessageFormat plural translations with the CLDR rules of the language that has the translation. getPlural returns a plural translation, getText fills plural placeholders in the language of the text it returns, and languages that load another language's translations can name the locale whose plural rules apply. --- README.md | 32 +++++++++++++++++++ composer.json | 3 ++ src/Locale/Locale.php | 72 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3a46fa..678452f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,38 @@ $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. They require the `intl` extension. + +```php + '{count, plural, one {in # minute} other {in # minutes}}', + 'expire' => 'This code will expire {{expire}}.', +]); +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." +``` + +When a language loads another language's translations, pass the locale whose plural rules apply: + +```php +=7.4" }, + "suggest": { + "ext-intl": "Required to format plural translations" + }, "require-dev": { "phpunit/phpunit": "^9.3", "vimeo/psalm": "4.0.1", diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php index 7b1cf73..6da283b 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 array $plurals Placeholder name => [plural key, count] * @param string|null $default * @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,51 @@ 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}}`. + * + * @throws Exception + */ + public function getPlural(string $key, int $count, string|null $default = self::DEFAULT_DYNAMIC_KEY): ?string + { + return $this->format($this->default, $key, $count) ?? ($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 + * + * @throws Exception + */ + protected function format(string $language, string $key, int $count): ?string + { + 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]); + } catch (\IntlException) { + continue; + } + + if (\is_string($text)) { + return $text; + } + } + + if (self::$exceptions) { + throw new Exception('Key named "'.$key.'" not found'); + } + + return null; + } + /** * Get list of configured transltions in specific language * From d3714bd8edd4404f3b39d8aeb35369b999e23209 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 18 Sep 2026 13:34:22 +0530 Subject: [PATCH 2/3] test: cover plural translations Run the suite with the intl extension so the plural paths are exercised, and cover CLDR category selection, the plural rules of a language that loads another language's translations, fallback formatting, invalid patterns and defaults. Plural counts accept floats, since CLDR gives fractions their own category, and patterns can take other ICU arguments. An invalid pattern now reports itself instead of looking like a missing translation. The intl extension moves to require: it is needed by a method on the only class this library has. The PHP constraint follows the typed class constant the code already uses. --- .github/workflows/test.yml | 2 +- README.md | 13 +++- composer.json | 6 +- src/Locale/Locale.php | 30 +++++++--- tests/Locale/LocaleTest.php | 114 +++++++++++++++++++++++++++++++++++- tests/en-plurals.json | 3 + 6 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 tests/en-plurals.json 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 678452f..5c063af 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ 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. They require the `intl` extension. +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 {через # минуты}}', @@ -72,8 +73,13 @@ 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 @@ -111,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 @@ -126,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 diff --git a/composer.json b/composer.json index e873c51..73c4fee 100755 --- a/composer.json +++ b/composer.json @@ -9,10 +9,8 @@ "psr-4": {"Utopia\\Locale\\": "src/Locale"} }, "require": { - "php": ">=7.4" - }, - "suggest": { - "ext-intl": "Required to format plural translations" + "php": ">=8.3", + "ext-intl": "*" }, "require-dev": { "phpunit/phpunit": "^9.3", diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php index 6da283b..8f27a22 100644 --- a/src/Locale/Locale.php +++ b/src/Locale/Locale.php @@ -135,9 +135,9 @@ public function setDefault(string $name): self * Plural placeholders are formatted in the same language as the translation they fill. * * @param string $key - * @param array $placeholders - * @param array $plurals Placeholder name => [plural key, count] * @param string|null $default + * @param array $placeholders + * @param array $plurals Placeholder name => [plural key, count] * @return mixed * * @throws Exception @@ -185,20 +185,32 @@ public function getText(string $key, string|null $default = self::DEFAULT_DYNAMI * 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 $count, string|null $default = self::DEFAULT_DYNAMIC_KEY): ?string + 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) ?? ($default === self::DEFAULT_DYNAMIC_KEY ? '{{'.$key.'}}' : $default); + 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 $count): ?string + 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; @@ -207,8 +219,10 @@ protected function format(string $language, string $key, int $count): ?string } try { - $text = (new \MessageFormatter(self::$rules[$name] ?? $name, $pattern))->format(['count' => $count]); - } catch (\IntlException) { + $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; } @@ -218,7 +232,7 @@ protected function format(string $language, string $key, int $count): ?string } if (self::$exceptions) { - throw new Exception('Key named "'.$key.'" not found'); + throw new Exception($invalid ?? 'Key named "'.$key.'" not found'); } return null; diff --git a/tests/Locale/LocaleTest.php b/tests/Locale/LocaleTest.php index cef251e..e20dec7 100755 --- a/tests/Locale/LocaleTest.php +++ b/tests/Locale/LocaleTest.php @@ -30,7 +30,30 @@ 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') ?: ''); + + $this->assertCount(10, Locale::getLanguages()); } public function tearDown(): void @@ -121,6 +144,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}}" +} From a4dca3d27c37ce9538c844e6ec32bc2b1b04731a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 18 Sep 2026 13:43:20 +0530 Subject: [PATCH 3/3] test: drop the registered language count from setUp --- tests/Locale/LocaleTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Locale/LocaleTest.php b/tests/Locale/LocaleTest.php index e20dec7..7da50bf 100755 --- a/tests/Locale/LocaleTest.php +++ b/tests/Locale/LocaleTest.php @@ -52,8 +52,6 @@ public function setUp(): void // 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') ?: ''); - - $this->assertCount(10, Locale::getLanguages()); } public function tearDown(): void