├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json └── src ├── DependencyInjection └── WebfactoryIcuTranslationExtension.php ├── Resources ├── config │ └── services.yml └── doc │ └── index.rst ├── Translator ├── FormatterDecorator.php ├── Formatting │ ├── AbstractFormatterDecorator.php │ ├── Analysis │ │ ├── MessageAnalyzer.php │ │ ├── MessageLexer.php │ │ └── MessageParser.php │ ├── DefaultParameterDecorator.php │ ├── Exception │ │ ├── CannotFormatException.php │ │ ├── CannotInstantiateFormatterException.php │ │ └── FormattingException.php │ ├── FormatterInterface.php │ ├── GracefulExceptionsDecorator.php │ ├── IntlFormatter.php │ ├── MissingParameterWarningDecorator.php │ └── TwigParameterNormalizer.php └── FormattingException.php ├── Twig └── IcuFormattingExtension.php └── WebfactoryIcuTranslationBundle.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@Symfony' => true, 6 | '@Symfony:risky' => true, 7 | 'array_syntax' => array('syntax' => 'short'), 8 | 'no_unreachable_default_argument_value' => false, 9 | 'braces' => array('allow_single_line_closure' => true), 10 | 'heredoc_to_nowdoc' => false, 11 | 'phpdoc_annotation_without_dot' => false, 12 | 'php_unit_test_annotation' => ['style' => 'annotation'], 13 | 'php_unit_method_casing' => false, 14 | 'psr_autoloading' => false, 15 | ]) 16 | ->setRiskyAllowed(true) 17 | ->setFinder( 18 | PhpCsFixer\Finder::create() 19 | ->in(__DIR__) 20 | ->notPath('conf/') 21 | ->notPath('tmp/') 22 | ->notPath('node_modules/') 23 | ->notPath('var/cache') 24 | ->notPath('vendor/') 25 | ->notPath('www') 26 | ) 27 | ; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 webfactory GmbH, Bonn (info@webfactory.de) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ICU Translation Bundle # 2 | 3 | [![Build Status](https://travis-ci.org/webfactory/WebfactoryIcuTranslationBundle.svg?branch=master)](https://travis-ci.org/webfactory/WebfactoryIcuTranslationBundle) 4 | [![Coverage Status](https://coveralls.io/repos/webfactory/icu-translation-bundle/badge.png?branch=master)](https://coveralls.io/r/webfactory/icu-translation-bundle?branch=master) 5 | 6 | While the [Symfony translation component](http://symfony.com/doc/current/components/translation/index.html) does a 7 | great job in most cases, it can become difficult to use if you need conditions other than numbers (e.g. gender) or 8 | nested conditions. This is where the ICU Translation Bundle steps in. Using the [International Components for Unicode 9 | project](http://site.icu-project.org/)'s standard message format, it enhances the Symfony component with arbitrary and 10 | nested conditions, as well as easy-to-use localized number and date formatting. The enhancement is non-invasive, i.e. 11 | you don't have to touch your former messages, they'll still work as usual. 12 | 13 | ## Installation ## 14 | 15 | Assuming you've already [enabled and configured the Symfony translation component](http://symfony.com/doc/current/book/translation.html#book-translation-configuration), 16 | all you have to do is to install the bundle via [composer](https://getcomposer.org) with something like this: 17 | 18 | php composer.phar require webfactory/icu-translation-bundle 19 | 20 | (We use [Semantic Versioning](http://semver.org/), so as soon as a version tagged 1.0.0 is available, you'll probably 21 | want to use something like ~1.0 as the version string.) 22 | 23 | As usual, enable the bundle in your kernel: 24 | 25 | get('translator'); 53 | $output = $translator->trans( 54 | 'message-number', 55 | array('%mile_to_metres%' => 1609.34) 56 | ); 57 | 58 | E.g. for the locale "en", the output will be "1 mile = 1,609.34 metres", while for the locale "de" it will be "1 mile = 59 | 1.609,34 metres" (or "1 Meile = 1.609,34 Meter" with a proper translation). 60 | 61 | For other parameter types such as date, see the bundle documentation. 62 | 63 | ### Gender Specific Translations ### 64 | 65 | Gender specific translations are a special case of arbitrary conditions. Conditions are denoted by the key word "select" 66 | after the variable, followed by possible variable values and their respective messages. See the following example 67 | message stored for the locale "en" under the key "message-gender": 68 | 69 | {gender, select, 70 | female {She spent all her money on horses} 71 | other {He spent all his money on horses} 72 | } 73 | 74 | If your controller looks something like this: 75 | 76 | $output = $translator->trans( 77 | 'message-gender', 78 | array('%gender%' => 'male') 79 | ); 80 | 81 | the output will be "He spent all his money on horses" for the locale "en". 82 | 83 | Why didn't we list "female" and "male" as possible variable values in the message, but "female" and "other" instead? 84 | Find out in the bundle documentation. 85 | 86 | ### More Readable Pluralization ### 87 | 88 | While [Symfony's translation component already supports pluralization](http://symfony.com/doc/current/components/translation/usage.html#component-translation-pluralization), 89 | we think the ICU Translation Bundle provides it in a more readable way. Analogously to conditions, pluralizations are 90 | denoted by the key word "plural" after the variable, followed by possible variable values and their respective messages. 91 | See the following example message stored for the locale "en" under the key "message-pluralization": 92 | 93 | {number_of_participants, plural, 94 | =0 {Nobody is participating} 95 | =1 {One person participates} 96 | other {# persons are participating} 97 | } 98 | 99 | If your controller looks something like this: 100 | 101 | $output = $translator->trans( 102 | 'message-pluralization', 103 | array('%number_of_participants%' => 2) 104 | ); 105 | 106 | The output for the locale "en" will be: "2 persons are participating". 107 | 108 | Note that you can distinguish both between exact numbers like with "=0" and [Unicode Common Locale Data Repository 109 | number categories](http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html) like "other". Also 110 | note that the number sign "#" becomes substituted with the value of the variable, 2 in this example. 111 | 112 | Now that you've got an idea of the ICU translation bundle's features, we once more invite you to read the [bundle 113 | documentation](Resources/doc/index.rst). 114 | 115 | ## Changelog ## 116 | 117 | ### 0.5.0 -> 0.6.0 ### 118 | 119 | Introduced logging of missing parameters. 120 | 121 | ### 0.4.0 -> 0.5.0 ### 122 | 123 | Improved performance by removing lexer/parser classes and message rewriting that was necessary to support named 124 | parameters in PHP versions < 5.5. 125 | 126 | ### 0.3.0 -> 0.4.0 ### 127 | 128 | Symfony 3 is now supported. 129 | 130 | ### 0.2.3 -> 0.3.0 ### 131 | 132 | Dropped support for PHP 5.3 and 5.4, added support for PHP 7. 133 | 134 | ### 0.2.2 -> 0.2.3 ### 135 | 136 | The ``GracefulExceptionsDecorator`` logs all types of exception now, not just instances of ``FormattingException``. 137 | 138 | Credits, Copyright and License 139 | ------------------------------ 140 | Copyright 2012-2019 webfactory GmbH, Bonn. Code released under [the MIT license](LICENSE). 141 | 142 | - 143 | - 144 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webfactory/icu-translation-bundle", 3 | "description": "Enables ICU message formatting for translations in Symfony applications.", 4 | "keywords": [ 5 | "symfony", 6 | "translator", 7 | "translation", 8 | "pluralization", 9 | "icu", 10 | "formatting", 11 | "message" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "webfactory GmbH", 17 | "email": "info@webfactory.de", 18 | "homepage": "http://www.webfactory.de", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^7.4 | 8.0.* | 8.1.*", 24 | "ext-intl": "*", 25 | "lib-icu": ">=4.8", 26 | "jms/parser-lib": "^1.0.0", 27 | "psr/log": "^1.0", 28 | "symfony/config": "^4.3|^5.0", 29 | "symfony/dependency-injection": "^4.4|^5.0", 30 | "symfony/finder": "^3.4.31|^4.0|^5.0", 31 | "symfony/framework-bundle": "^4.4|^5.0", 32 | "symfony/http-kernel": "^4.4|^5.0", 33 | "symfony/translation": "^4.2|^5.0", 34 | "symfony/translation-contracts": "^2.0|^3.0", 35 | "twig/twig": "^1.42|^2.0|^3.0" 36 | }, 37 | "require-dev": { 38 | "phpunit/phpunit": "^8.5 | ^9.0", 39 | "symfony/monolog-bundle": "^3.4.31", 40 | "symfony/phpunit-bridge": ">= 5.0", 41 | "symfony/twig-bundle": "^3.4.31|^4.0|^5.0", 42 | "symfony/yaml": "^4.4|^5.0" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "Webfactory\\IcuTranslationBundle\\": "src" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Webfactory\\IcuTranslationBundle\\Tests\\": "tests" 52 | } 53 | }, 54 | "config": { 55 | "sort-packages": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/DependencyInjection/WebfactoryIcuTranslationExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yml'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | 6 | Webfactory\IcuTranslationBundle\Translator\Formatting\: 7 | resource: '../../Translator/Formatting/*.php' 8 | 9 | Webfactory\IcuTranslationBundle\Twig\: 10 | resource: '../../Twig' 11 | 12 | webfactory_icu_translation.formatter.intl_formatter: 13 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\IntlFormatter 14 | deprecated: ~ 15 | 16 | Webfactory\IcuTranslationBundle\Translator\Formatting\DefaultParameterDecorator: 17 | arguments: 18 | - '@Webfactory\IcuTranslationBundle\Translator\Formatting\IntlFormatter' 19 | 20 | webfactory_icu_translation.formatter.default_parameters: 21 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\DefaultParameterDecorator 22 | deprecated: ~ 23 | 24 | Webfactory\IcuTranslationBundle\Translator\Formatting\MissingParameterWarningDecorator: 25 | arguments: 26 | - '@Webfactory\IcuTranslationBundle\Translator\Formatting\DefaultParameterDecorator' 27 | - '@?logger' 28 | tags: 29 | - { name: monolog.logger, channel: webfactory_icu_translation } 30 | 31 | webfactory_icu_translation.formatter.missing_parameters: 32 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\MissingParameterWarningDecorator 33 | deprecated: ~ 34 | 35 | Webfactory\IcuTranslationBundle\Translator\Formatting\TwigParameterNormalizer: 36 | arguments: 37 | - '@Webfactory\IcuTranslationBundle\Translator\Formatting\MissingParameterWarningDecorator' 38 | 39 | webfactory_icu_translation.formatter.twig_parameter_normalizer: 40 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\TwigParameterNormalizer 41 | deprecated: ~ 42 | 43 | Webfactory\IcuTranslationBundle\Translator\Formatting\GracefulExceptionsDecorator: 44 | class: Webfactory\IcuTranslationBundle\Translator\Formatting\GracefulExceptionsDecorator 45 | arguments: 46 | - '@Webfactory\IcuTranslationBundle\Translator\Formatting\TwigParameterNormalizer' 47 | - '@?logger' 48 | tags: 49 | - { name: monolog.logger, channel: webfactory_icu_translation } 50 | 51 | webfactory_icu_translation.formatter.graceful_exceptions: 52 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\GracefulExceptionsDecorator 53 | deprecated: ~ 54 | 55 | Webfactory\IcuTranslationBundle\Translator\FormatterDecorator: 56 | decorates: translator 57 | arguments: 58 | - '@Webfactory\IcuTranslationBundle\Translator\FormatterDecorator.inner' 59 | - '@Webfactory\IcuTranslationBundle\Translator\Formatting\FormatterInterface' 60 | 61 | # Then top/end of the decoration stack set up by this bundle: 62 | webfactory_icu_translation.translator: 63 | alias: Webfactory\IcuTranslationBundle\Translator\FormatterDecorator 64 | 65 | # The top end of the Formatter stack set up by this bundle: 66 | Webfactory\IcuTranslationBundle\Translator\Formatting\FormatterInterface: 67 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\GracefulExceptionsDecorator 68 | 69 | webfactory_icu_translation.formatter: 70 | alias: Webfactory\IcuTranslationBundle\Translator\Formatting\FormatterInterface 71 | deprecated: ~ 72 | -------------------------------------------------------------------------------- /src/Resources/doc/index.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | New Translation Features 3 | ======================== 4 | 5 | Using the `International Components for Unicode project `_'s standard message format, the 6 | ICU Translation Bundle enhances the `Symfony translation component `_ 7 | with arbitrary and nested conditions, as well as easy-to-use localized number and date formatting. The enhancement is 8 | non-invasive, i.e. you don't have to touch your former messages, they'll still work as usual. 9 | 10 | The following will introduce you to the new translation message features and format. 11 | 12 | 13 | Variable Replacement and Parameter Types 14 | ---------------------------------------- 15 | 16 | Variable names are placed within curly braces and are replaced by concrete values during translation:: 17 | 18 | Hello {name}! 19 | 20 | Variables can also have a parameter type, which is noted after the variable name, separated by a comma. 21 | 22 | For the **"number"** parameter type, the output gets localized with the correct thousands separator and decimal mark. See 23 | this example message stored under the key "message-number":: 24 | 25 | 1 mile = {mile_to_metres, number} metres 26 | 27 | In a controller, the example could look like this:: 28 | 29 | $translator = this->get('translator'); 30 | $output = $translator->trans( 31 | 'message-number', 32 | array('%mile_to_metres%' => 1609.34) 33 | ); 34 | 35 | For the locale "en", the output will be "1 mile = 1,609.34 metres", while for the locale "de" it will be "1 mile = 36 | 1.609,34 metres" (or "1 Meile = 1.609,34 Meter" with a proper translation). 37 | 38 | By marking a number as **"currency"**, the currency symbol will be automatically added at the localised position:: 39 | 40 | Available for just {price, number, currency} 41 | 42 | Output in en_GB: "Available for just £99.99", output in de_DE: "Available for just 99,99 €". 43 | 44 | For variables that are considered a **"date"**, local formats are available:: 45 | 46 | Born on {birthDate, date, short} 47 | 48 | Output in en_GB: "Born on 04/02/1986", output in de_DE: "Born on 04.02.86". 49 | 50 | 51 | Conditions 52 | ---------- 53 | 54 | You may use conditions to provide translations for different circumstances, e.g. the gender of a sentence's subject. 55 | Conditions are denoted by the key word "select" after the variable, followed by possible variable values and their 56 | respective messages. See the following example message stored for the locale "en" under the key "message-gender":: 57 | 58 | {gender, select, 59 | female {She spent all her money on horses} 60 | other {He spent all his money on horses} 61 | } 62 | 63 | If your controller looks something like this:: 64 | 65 | $output = $translator->trans( 66 | 'message-gender', 67 | array('%gender%' => 'male') 68 | ); 69 | 70 | the output will be "He spent all his money on horses" for the locale "en". 71 | 72 | **Note**: Each conditional statement needs an "other" section. If that section is missing, then an error will occur when 73 | the translation is used on the website. 74 | 75 | 76 | Nested Conditions 77 | ~~~~~~~~~~~~~~~~~ 78 | 79 | You may nest conditions for more complex scenarios:: 80 | 81 | {course, select, 82 | translating_for_beginners {{gender_of_participant, select, 83 | female {She participated in the course Translating for Beginners.} 84 | other {He participated in the course Translating for Beginners.} 85 | }} 86 | advanced_translation_methods {{gender_of_participant, select, 87 | female {She participated in the course Advanced Translation Methods.} 88 | other {He participated in the course Advanced Translation Methods.} 89 | }} 90 | other {Unknown course.} 91 | } 92 | 93 | For readability, you may want to write the conditions with the most cases more on the outside. 94 | 95 | 96 | Long Translations 97 | ~~~~~~~~~~~~~~~~~ 98 | 99 | You may split long translations in conditions into several lines:: 100 | 101 | {gender_of_participant, select, 102 | female { 103 | She 104 | participated 105 | in 106 | the 107 | course. 108 | } 109 | other {He participated in the course.} 110 | } 111 | 112 | In this case, the sentence contains additional whitespace at the start and at the end, but this is 113 | usually not a problem when used in a HTML context. 114 | 115 | If a translation must not contain leading and trailing whitespace, then it has to be enclosed directly 116 | by the curly braces:: 117 | 118 | {gender_of_participant, select, 119 | female {She 120 | participated 121 | in 122 | the 123 | course.} 124 | other {He participated in the course.} 125 | } 126 | 127 | 128 | Pluralization 129 | ------------- 130 | 131 | While `Symfony's translation component `_ already 132 | supports pluralization, we think the ICU Translation Bundle provides it in a more readable way. Analogously to 133 | conditions, pluralizations are denoted by the key word "plural" after the variable, followed by possible variable values 134 | and their respective messages. See the following example message stored for the locale "en" under the key 135 | "message-pluralization":: 136 | 137 | {number_of_participants, plural, 138 | =0 {Nobody is participating} 139 | =1 {One person participates} 140 | other {# persons are participating} 141 | } 142 | 143 | If your controller looks something like this:: 144 | 145 | $output = $translator->trans( 146 | 'message-pluralization', 147 | array('%number_of_participants%' => 2) 148 | ); 149 | 150 | The output for the locale "en" will be: "2 persons are participating". 151 | 152 | You may have noticed three issues: 153 | 154 | 1. To distinguish between exact numbers, you use the equals sign in front of the number. 155 | 2. The number sign "#" in a message becomes substituted with the value of the variable, 2 in this example. 156 | 3. You can distinguish both between exact numbers like with "=0" and something different like "other". Those are called 157 | number categories. 158 | 159 | Number Categories 160 | ~~~~~~~~~~~~~~~~~ 161 | 162 | Some languages have more forms of number specific grammar and vocabulary. E.g. English has two forms: singular and 163 | plural, while Bambara has only one form and Arabic has six. To abstract these forms for translations, the ICU Translation 164 | Bundle supports the `Unicode Common Locale Data Repository number categories `_. 165 | 166 | E.g. for English, these number categories are named "one" and "other". You use them as follows in your message:: 167 | 168 | {number_of_participants, plural, 169 | one {One person participates.} 170 | other {{number_of_participants, number} persons are participating.} 171 | } 172 | 173 | 174 | Escaping Special Characters 175 | --------------------------- 176 | 177 | Any character can be used within translations. But curly braces and single quotes have to be escaped. 178 | 179 | Escape curly braces by wrapping them in single quotes:: 180 | 181 | This '{'token'}' is escaped 182 | 183 | The output of this message will be "This {token} is escaped". 184 | 185 | Escape single quotes by preceding them with another single quote:: 186 | 187 | The character '' is called single quote 188 | 189 | This message is transformed into "The character ' is called single quote". 190 | -------------------------------------------------------------------------------- /src/Translator/FormatterDecorator.php: -------------------------------------------------------------------------------- 1 | translator = $translator; 33 | $this->formatter = $formatter; 34 | } 35 | 36 | public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null): string 37 | { 38 | $message = $this->translator->trans($id, $parameters, $domain, $locale); 39 | 40 | return $this->handleFormatting($id, $message, $parameters, $locale); 41 | } 42 | 43 | public function setLocale(string $locale): void 44 | { 45 | $this->translator->setLocale($locale); 46 | } 47 | 48 | public function getLocale(): string 49 | { 50 | return $this->translator->getLocale(); 51 | } 52 | 53 | /** 54 | * Formats the message if possible and throws a normalized exception in case of error. 55 | * 56 | * @throws \Webfactory\IcuTranslationBundle\Translator\FormattingException if formatting fails 57 | */ 58 | protected function handleFormatting(string $id, string $message, array $parameters = [], string $locale = null): string 59 | { 60 | if (empty($message)) { 61 | // No formatting needed. 62 | return $message; 63 | } 64 | 65 | $locale = $this->toLocale($locale); 66 | 67 | try { 68 | return $this->format($message, $parameters, $locale); 69 | } catch (\Exception $e) { 70 | throw new FormattingException($locale, $id, $message, $parameters, $e); 71 | } 72 | } 73 | 74 | /** 75 | * Applies Intl formatting on the provided message. 76 | */ 77 | protected function format(string $message, array $parameters = [], string $locale = null): string 78 | { 79 | return $this->formatter->format($locale, $message, $parameters); 80 | } 81 | 82 | /** 83 | * Returns a valid locale. 84 | * 85 | * If a correct locale is provided that one is used. 86 | * Otherwise, the default locale is returned. 87 | */ 88 | protected function toLocale(string $locale = null): string 89 | { 90 | return $locale ?? $this->getLocale(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Translator/Formatting/AbstractFormatterDecorator.php: -------------------------------------------------------------------------------- 1 | innerFormatter = $innerFormatter; 25 | } 26 | 27 | /** 28 | * Formats the provided message. 29 | * 30 | * @param string $locale 31 | * @param string $message 32 | * @param array(string=>mixed) $parameters 33 | * 34 | * @return string the formatted message 35 | */ 36 | public function format($locale, $message, array $parameters) 37 | { 38 | return $this->innerFormatter->format( 39 | $this->preProcessLocale($locale), 40 | $this->preProcessMessage($message), 41 | $this->preProcessParameters($parameters) 42 | ); 43 | } 44 | 45 | /** 46 | * Pre-processes the locale before it is passed to the inner formatter. 47 | * 48 | * @param string $locale for example 'en' or 'de_DE' 49 | * 50 | * @return string 51 | */ 52 | protected function preProcessLocale($locale) 53 | { 54 | return $locale; 55 | } 56 | 57 | /** 58 | * Pre-processes the message before it is passed to the inner formatter. 59 | * 60 | * @param string $message the translation message 61 | * 62 | * @return string 63 | */ 64 | protected function preProcessMessage($message) 65 | { 66 | return $message; 67 | } 68 | 69 | /** 70 | * Pre-processes the parameters before these are passed to the inner formatter. 71 | * 72 | * @param array(string=>mixed) $parameters 73 | * 74 | * @return array(string=>mixed) 75 | */ 76 | protected function preProcessParameters(array $parameters) 77 | { 78 | return $parameters; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Translator/Formatting/Analysis/MessageAnalyzer.php: -------------------------------------------------------------------------------- 1 | message = $message; 25 | } 26 | 27 | /** 28 | * Returns the names of the parameters that are used in the message. 29 | * 30 | * Example: 31 | * 32 | * // Will return ['description', 'name']. 33 | * $parameterNames = (new MessageAnalyzer('Hello {'description'} {name}'))->getParameters(); 34 | * 35 | * The analyzer works on a best-effort basis: As placeholder nesting can be quite complex it does neither 36 | * guarantee that all parameters are found nor that the message really uses all of the returned 37 | * parameters. 38 | * 39 | * @return string[] 40 | */ 41 | public function getParameters() 42 | { 43 | $parameters = []; 44 | $tokens = (new MessageParser(new MessageLexer()))->parse($this->message); 45 | foreach ($tokens as $token) { 46 | /* @var $token array */ 47 | if (MessageParser::TOKEN_PARAMETER_NAME === $token[0]) { 48 | $parameters[] = $token[1]; 49 | } 50 | } 51 | 52 | return array_values(array_unique($parameters)); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Translator/Formatting/Analysis/MessageLexer.php: -------------------------------------------------------------------------------- 1 | > the message tokens 80 | */ 81 | public function parse($message, $context = null) 82 | { 83 | if (false === strpos($message, '{')) { 84 | // Message does not contain any declarations, therefore, we can avoid 85 | // the parsing process. 86 | return [[MessageLexer::TOKEN_TEXT, $message]]; 87 | } 88 | 89 | return parent::parse($message, $context); 90 | } 91 | 92 | /** 93 | * Performs the parsing and creates the result that is returned by parse(). 94 | * 95 | * @return array> the message tokens 96 | */ 97 | protected function parseInternal() 98 | { 99 | $this->state = new \SplStack(); 100 | $this->enterState(self::STATE_TEXT); 101 | $tokens = []; 102 | $this->lexer->moveNext(); 103 | while (null !== $this->lexer->token) { 104 | $tokenType = $this->getTokenType(); 105 | 106 | if ($this->isState(self::STATE_TEXT)) { 107 | if ($this->isToken(MessageLexer::TOKEN_SINGLE_QUOTE)) { 108 | $this->enterState(self::STATE_QUOTED_TEXT); 109 | } elseif ($this->isToken(MessageLexer::TOKEN_OPENING_BRACE)) { 110 | // Enter a new declaration scope. 111 | $this->enterState(self::STATE_DECLARATION_START); 112 | } elseif ($this->isToken(MessageLexer::TOKEN_CLOSING_BRACE)) { 113 | $this->leaveState(); 114 | } 115 | } elseif ($this->isState(self::STATE_DECLARATION_START)) { 116 | if ($this->isToken(MessageLexer::TOKEN_TEXT)) { 117 | $this->swapState(self::STATE_DECLARATION_VARIABLE); 118 | $tokenType = self::TOKEN_PARAMETER_NAME; 119 | } 120 | } elseif ($this->isState(self::STATE_DECLARATION_VARIABLE)) { 121 | if ($this->isToken(MessageLexer::TOKEN_COMMA)) { 122 | $this->swapState(self::STATE_DECLARATION_OPERATION); 123 | } elseif ($this->isToken(MessageLexer::TOKEN_CLOSING_BRACE)) { 124 | $this->leaveState(); 125 | } 126 | } elseif ($this->isState(self::STATE_DECLARATION_OPERATION)) { 127 | if ($this->isToken(MessageLexer::TOKEN_TEXT)) { 128 | if (\in_array($this->getTokenValue(), ['select', 'choice', 'plural'])) { 129 | $this->swapState(self::STATE_DECLARATION_EXPRESSION); 130 | $tokenType = self::TOKEN_CHOICE_TYPE; 131 | } 132 | } elseif ($this->isToken(MessageLexer::TOKEN_COMMA)) { 133 | $this->swapState(self::STATE_DECLARATION_ARGUMENT); 134 | } elseif ($this->isToken(MessageLexer::TOKEN_CLOSING_BRACE)) { 135 | $this->leaveState(); 136 | } 137 | } elseif ($this->isState(self::STATE_DECLARATION_ARGUMENT)) { 138 | if ($this->isToken(MessageLexer::TOKEN_CLOSING_BRACE)) { 139 | $this->leaveState(); 140 | } 141 | } elseif ($this->isState(self::STATE_DECLARATION_EXPRESSION)) { 142 | if ($this->isToken(MessageLexer::TOKEN_OPENING_BRACE)) { 143 | $this->enterState(self::STATE_TEXT); 144 | } 145 | } elseif ($this->isState(self::STATE_QUOTED_TEXT)) { 146 | if ($this->isToken(MessageLexer::TOKEN_SINGLE_QUOTE)) { 147 | $this->leaveState(); 148 | } 149 | } 150 | 151 | $tokens[] = [$tokenType, $this->getTokenValue()]; 152 | 153 | $this->lexer->moveNext(); 154 | } 155 | 156 | return $tokens; 157 | } 158 | 159 | /** 160 | * Checks if the current token has the provided type. 161 | * 162 | * @param int $type 163 | * 164 | * @return bool 165 | */ 166 | private function isToken($type) 167 | { 168 | return $this->getTokenType() === $type; 169 | } 170 | 171 | /** 172 | * Returns the type of the current token. 173 | * 174 | * @return int one of the MessageLexer::TOKEN_* constants 175 | */ 176 | private function getTokenType() 177 | { 178 | return $this->lexer->token[2]; 179 | } 180 | 181 | /** 182 | * Returns the value of the current token. 183 | * 184 | * @return string 185 | */ 186 | private function getTokenValue() 187 | { 188 | return $this->lexer->token[0]; 189 | } 190 | 191 | /** 192 | * Sets $newState as new parsing state. 193 | * 194 | * The previous state is preserved. 195 | * 196 | * @param string $newState 197 | */ 198 | private function enterState($newState) 199 | { 200 | $this->state->push($newState); 201 | } 202 | 203 | /** 204 | * Leaves the current state and restores the previous one. 205 | */ 206 | private function leaveState() 207 | { 208 | $this->state->pop(); 209 | } 210 | 211 | /** 212 | * Removes the current state and replaces it by $newState. 213 | * 214 | * @param string $newState 215 | */ 216 | private function swapState($newState) 217 | { 218 | $this->leaveState(); 219 | $this->enterState($newState); 220 | } 221 | 222 | /** 223 | * Checks if $checkedState is the current state. 224 | * 225 | * @param string $checkedState 226 | * 227 | * @return bool 228 | */ 229 | private function isState($checkedState) 230 | { 231 | return $this->state->top() === $checkedState; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Translator/Formatting/DefaultParameterDecorator.php: -------------------------------------------------------------------------------- 1 | mixed) $parameters 27 | * 28 | * @return string the formatted message 29 | */ 30 | public function format($locale, $message, array $parameters) 31 | { 32 | $variables = (new MessageAnalyzer($message))->getParameters(); 33 | $defaults = array_fill_keys($variables, null); 34 | $parameters = $parameters + $defaults; 35 | 36 | return parent::format($locale, $message, $parameters); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Translator/Formatting/Exception/CannotFormatException.php: -------------------------------------------------------------------------------- 1 | mixed) $parameters 16 | * 17 | * @return string the formatted message 18 | */ 19 | public function format($locale, $message, array $parameters); 20 | } 21 | -------------------------------------------------------------------------------- /src/Translator/Formatting/GracefulExceptionsDecorator.php: -------------------------------------------------------------------------------- 1 | logger = (null !== $logger) ? $logger : new NullLogger(); 28 | } 29 | 30 | /** 31 | * Formats the provided message. 32 | * 33 | * @param string $locale 34 | * @param string $message 35 | * @param array(string=>mixed) $parameters 36 | * 37 | * @return string the formatted message 38 | */ 39 | public function format($locale, $message, array $parameters) 40 | { 41 | try { 42 | return parent::format($locale, $message, $parameters); 43 | } catch (\Exception $e) { 44 | $this->logger->warning( 45 | 'Formatting translation failed.', 46 | [ 47 | 'locale' => $locale, 48 | 'message' => $message, 49 | 'parameters' => $parameters, 50 | 'exception' => $e, 51 | ] 52 | ); 53 | 54 | return ' (message formatting error)'; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Translator/Formatting/IntlFormatter.php: -------------------------------------------------------------------------------- 1 | mixed) $parameters 19 | * 20 | * @return string 21 | * 22 | * @throws CannotInstantiateFormatterException if the message pattern cannot be used 23 | * @throws CannotFormatException if an error occurs during formatting 24 | */ 25 | public function format($locale, $message, array $parameters) 26 | { 27 | if (empty($message)) { 28 | // Empty strings are not accepted as message pattern by the \MessageFormatter. 29 | return $message; 30 | } 31 | 32 | try { 33 | $useExceptions = ini_set('intl.use_exceptions', true); 34 | $formatter = new \MessageFormatter($locale, $message); 35 | } catch (\Exception $e) { 36 | throw new CannotInstantiateFormatterException("Error creating the MessageFormatter, probably the message is not valid: \"$message\"", 0, $e); 37 | } finally { 38 | ini_set('intl.use_exceptions', $useExceptions); 39 | } 40 | 41 | $result = $formatter->format($parameters); 42 | 43 | if (false === $result) { 44 | throw new CannotFormatException($formatter->getErrorMessage(), $formatter->getErrorCode()); 45 | } 46 | 47 | return $result; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Translator/Formatting/MissingParameterWarningDecorator.php: -------------------------------------------------------------------------------- 1 | logger = (null !== $logger) ? $logger : new NullLogger(); 33 | } 34 | 35 | /** 36 | * Checks if all mentioned parameters are provided. 37 | * 38 | * @param string $locale 39 | * @param string $message 40 | * @param array(string=>mixed) $parameters 41 | * 42 | * @return string the formatted message 43 | */ 44 | public function format($locale, $message, array $parameters) 45 | { 46 | $this->logIfParameterIsMissing($locale, $message, $parameters); 47 | 48 | return parent::format($locale, $message, $parameters); 49 | } 50 | 51 | /** 52 | * @param string $locale 53 | * @param string $message 54 | * @param array(string=>mixed) $parameters 55 | */ 56 | private function logIfParameterIsMissing($locale, $message, array $parameters) 57 | { 58 | $usedParameters = (new MessageAnalyzer($message))->getParameters(); 59 | $availableParameters = array_keys($parameters); 60 | $missingParameters = array_diff($usedParameters, $availableParameters); 61 | if (0 === \count($missingParameters)) { 62 | // All parameters available, nothing to do. 63 | return; 64 | } 65 | $logMessage = 'The parameters [%s] might be missing in locale "%s" in the message "%s".'; 66 | $logMessage = sprintf($logMessage, implode(',', $missingParameters), $locale, $message); 67 | $this->logger->warning( 68 | $logMessage, 69 | [ 70 | 'locale' => $locale, 71 | 'message' => $message, 72 | 'parameters' => $parameters, 73 | 'usedParameters' => $usedParameters, 74 | 'missingParameters' => $missingParameters, 75 | // Add an exception (but do not throw it) to ensure that we get a stack trace. 76 | 'exception' => new FormattingException('Some message parameters are missing.'), 77 | ] 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Translator/Formatting/TwigParameterNormalizer.php: -------------------------------------------------------------------------------- 1 | mixed) $parameters 15 | * 16 | * @return array(string=>mixed) 17 | */ 18 | protected function preProcessParameters(array $parameters) 19 | { 20 | foreach ($parameters as $name => $value) { 21 | /* @var $name string|integer */ 22 | /* @var $value mixed */ 23 | if (\is_int($name)) { 24 | // Do *not* convert integer keys to string. 25 | continue; 26 | } 27 | $newName = trim($name, '%'); 28 | if ($name === $newName) { 29 | // Name did not use Twig delimiters. 30 | continue; 31 | } 32 | if (!isset($parameters[$newName])) { 33 | // Parameter does not conflict with an existing one, therefore, 34 | // pass it under the new name. 35 | $parameters[$newName] = $value; 36 | } 37 | unset($parameters[$name]); 38 | } 39 | 40 | return $parameters; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Translator/FormattingException.php: -------------------------------------------------------------------------------- 1 | toMessage($locale, $messageId, $messagePattern, $parameters, $previous->getMessage()); 49 | parent::__construct($message, $previous->getCode(), $previous); 50 | $this->locale = $locale; 51 | $this->messageId = $messageId; 52 | $this->messagePattern = $messagePattern; 53 | $this->parameters = $parameters; 54 | } 55 | 56 | /** 57 | * Returns the locale of the message. 58 | * 59 | * @return string 60 | */ 61 | public function getLocale() 62 | { 63 | return $this->locale; 64 | } 65 | 66 | /** 67 | * Returns the message ID of the affected translation. 68 | * 69 | * @return string 70 | */ 71 | public function getMessageId() 72 | { 73 | return $this->messageId; 74 | } 75 | 76 | /** 77 | * Returns the message pattern of the affected translation. 78 | * 79 | * @return string 80 | */ 81 | public function getMessagePattern() 82 | { 83 | return $this->messagePattern; 84 | } 85 | 86 | /** 87 | * Returns the parameters that have been passed. 88 | * 89 | * @return array(mixed) 90 | */ 91 | public function getParameters() 92 | { 93 | return $this->parameters; 94 | } 95 | 96 | /** 97 | * Creates an error message. 98 | * 99 | * @param string $locale the used locale (for example "en") 100 | * @param string $messageId the translation message ID 101 | * @param string $messagePattern the translation message pattern 102 | * @param array(mixed) $parameters 103 | * @param string $error description of the error that occurred 104 | * 105 | * @return string 106 | */ 107 | protected function toMessage($locale, $messageId, $messagePattern, array $parameters, $error) 108 | { 109 | $message = 'Cannot format translation:'.\PHP_EOL 110 | .'-> Locale: %s'.\PHP_EOL 111 | .'-> Message-ID: %s'.\PHP_EOL 112 | .'-> Message-Pattern:'.\PHP_EOL 113 | .'%s'.\PHP_EOL 114 | .'-> Parameters:'.\PHP_EOL 115 | .'%s'.\PHP_EOL 116 | .'-> Error:'.\PHP_EOL 117 | .'%s'; 118 | $message = sprintf($message, $locale, $messageId, $messagePattern, print_r($parameters, true), $error); 119 | 120 | return $message; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Twig/IcuFormattingExtension.php: -------------------------------------------------------------------------------- 1 | formatter = $formatter; 25 | $this->translator = $translator; 26 | } 27 | 28 | public function getFilters(): array 29 | { 30 | return [ 31 | new TwigFilter('icu_format', [$this, 'format']), 32 | ]; 33 | } 34 | 35 | public function format(string $message = '', array $parameters = [], string $locale = null): string 36 | { 37 | return $this->formatter->format($locale ?: $this->translator->getLocale(), $message, $parameters); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/WebfactoryIcuTranslationBundle.php: -------------------------------------------------------------------------------- 1 |