├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── LICENSE ├── README.md ├── dist │ ├── formatters │ │ ├── formatter.d.ts │ │ └── intl-formatter.d.ts │ ├── translator.d.ts │ ├── translator_controller.d.ts │ ├── translator_controller.js │ └── utils.d.ts └── package.json ├── composer.json ├── config └── services.php └── src ├── CacheWarmer └── TranslationsCacheWarmer.php ├── DependencyInjection ├── Configuration.php └── UxTranslatorExtension.php ├── Intl ├── ErrorKind.php ├── IntlMessageParser.php ├── Location.php ├── Position.php ├── SkeletonType.php ├── Type.php └── Utils.php ├── MessageParameters ├── Extractor │ ├── ExtractorInterface.php │ ├── IntlMessageParametersExtractor.php │ └── MessageParametersExtractor.php └── Printer │ └── TypeScriptMessageParametersPrinter.php ├── TranslationsDumper.php └── UxTranslatorBundle.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.22.0 4 | 5 | - Support both the Symfony format (`fr_FR`) and W3C specification (`fr-FR`) for locale subcodes. 6 | 7 | ## 2.20.0 8 | 9 | - Add `throwWhenNotFound` function to configure the behavior when a translation is not found. 10 | 11 | ## 2.19.0 12 | 13 | - Add configuration to filter dumped translations by domain. 14 | 15 | ## 2.16.0 16 | 17 | - Increase version range of `intl-messageformat` to `^10.5.11`, in order to see 18 | a faster implementation of ICU messages parsing. #1443 19 | 20 | ## 2.13.2 21 | 22 | - Revert "Change JavaScript package to `type: module`" 23 | 24 | ## 2.13.0 25 | 26 | - Add Symfony 7 support. 27 | - Change JavaScript package to `type: module` 28 | 29 | ## 2.9.0 30 | 31 | - Add support for symfony/asset-mapper 32 | 33 | ## 2.8.0 34 | 35 | - Component added 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony UX Translator 2 | 3 | **EXPERIMENTAL** This component is currently experimental and is 4 | likely to change, or even change drastically. 5 | 6 | Symfony UX Translator integrates [Symfony Translation](https://symfony.com/doc/current/translation.html) for JavaScript. 7 | 8 | **This repository is a READ-ONLY sub-tree split**. See 9 | https://github.com/symfony/ux to create issues or submit pull requests. 10 | 11 | ## Resources 12 | 13 | - [Documentation](https://symfony.com/bundles/ux-translator/current/index.html) 14 | - [Report issues](https://github.com/symfony/ux/issues) and 15 | [send Pull Requests](https://github.com/symfony/ux/pulls) 16 | in the [main Symfony UX repository](https://github.com/symfony/ux) 17 | -------------------------------------------------------------------------------- /assets/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # @symfony/ux-translator 2 | 3 | JavaScript assets of the [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) PHP package. 4 | 5 | ## Installation 6 | 7 | This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). 8 | 9 | We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. 10 | 11 | If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-translator](https://packagist.org/packages/symfony/ux-translator) PHP package version: 12 | ```shell 13 | composer require symfony/ux-translator:2.23.0 14 | npm add @symfony/ux-translator@2.23.0 15 | ``` 16 | 17 | **Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. 18 | 19 | ## Resources 20 | 21 | - [Documentation](https://symfony.com/bundles/ux-translator/current/index.html) 22 | - [Report issues](https://github.com/symfony/ux/issues) and 23 | [send Pull Requests](https://github.com/symfony/ux/pulls) 24 | in the [main Symfony UX repository](https://github.com/symfony/ux) 25 | -------------------------------------------------------------------------------- /assets/dist/formatters/formatter.d.ts: -------------------------------------------------------------------------------- 1 | export declare function format(id: string, parameters: Record, locale: string): string; 2 | -------------------------------------------------------------------------------- /assets/dist/formatters/intl-formatter.d.ts: -------------------------------------------------------------------------------- 1 | export declare function formatIntl(id: string, parameters: Record, locale: string): string; 2 | -------------------------------------------------------------------------------- /assets/dist/translator.d.ts: -------------------------------------------------------------------------------- 1 | export type DomainType = string; 2 | export type LocaleType = string; 3 | export type TranslationsType = Record; 6 | export type NoParametersType = Record; 7 | export type ParametersType = Record | NoParametersType; 8 | export type RemoveIntlIcuSuffix = T extends `${infer U}+intl-icu` ? U : T; 9 | export type DomainsOf = M extends Message ? keyof Translations : never; 10 | export type LocaleOf = M extends Message ? Locale : never; 11 | export type ParametersOf = M extends Message ? Translations[D] extends { 12 | parameters: infer Parameters; 13 | } ? Parameters : never : never; 14 | export interface Message { 15 | id: string; 16 | translations: { 17 | [domain in DomainType]: { 18 | [locale in Locale]: string; 19 | }; 20 | }; 21 | } 22 | export declare function setLocale(locale: LocaleType | null): void; 23 | export declare function getLocale(): LocaleType; 24 | export declare function throwWhenNotFound(enabled: boolean): void; 25 | export declare function setLocaleFallbacks(localeFallbacks: Record): void; 26 | export declare function getLocaleFallbacks(): Record; 27 | export declare function trans, D extends DomainsOf, P extends ParametersOf>(...args: P extends NoParametersType ? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix, locale?: LocaleOf] : [message: M, parameters: P, domain?: RemoveIntlIcuSuffix, locale?: LocaleOf]): string; 28 | -------------------------------------------------------------------------------- /assets/dist/translator_controller.d.ts: -------------------------------------------------------------------------------- 1 | export * from './translator'; 2 | -------------------------------------------------------------------------------- /assets/dist/translator_controller.js: -------------------------------------------------------------------------------- 1 | import { IntlMessageFormat } from 'intl-messageformat'; 2 | 3 | function strtr(string, replacePairs) { 4 | const regex = Object.entries(replacePairs).map(([from]) => { 5 | return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); 6 | }); 7 | if (regex.length === 0) { 8 | return string; 9 | } 10 | return string.replace(new RegExp(regex.join('|'), 'g'), (matched) => replacePairs[matched].toString()); 11 | } 12 | 13 | function format(id, parameters, locale) { 14 | if (null === id || '' === id) { 15 | return ''; 16 | } 17 | if (typeof parameters['%count%'] === 'undefined' || Number.isNaN(parameters['%count%'])) { 18 | return strtr(id, parameters); 19 | } 20 | const number = Number(parameters['%count%']); 21 | let parts = []; 22 | if (/^\|+$/.test(id)) { 23 | parts = id.split('|'); 24 | } 25 | else { 26 | parts = id.match(/(?:\|\||[^|])+/g) || []; 27 | } 28 | const intervalRegex = /^(?({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?[[\]])\s*(?-Inf|-?\d+(\.\d+)?)\s*,\s*(?\+?Inf|-?\d+(\.\d+)?)\s*(?[[\]]))\s*(?.*?)$/s; 29 | const standardRules = []; 30 | for (let part of parts) { 31 | part = part.trim().replace(/\|\|/g, '|'); 32 | const matches = part.match(intervalRegex); 33 | if (matches) { 34 | const matchGroups = matches.groups || {}; 35 | if (matches[2]) { 36 | for (const n of matches[3].split(',')) { 37 | if (number === Number(n)) { 38 | return strtr(matchGroups.message, parameters); 39 | } 40 | } 41 | } 42 | else { 43 | const leftNumber = '-Inf' === matchGroups.left ? Number.NEGATIVE_INFINITY : Number(matchGroups.left); 44 | const rightNumber = ['Inf', '+Inf'].includes(matchGroups.right) 45 | ? Number.POSITIVE_INFINITY 46 | : Number(matchGroups.right); 47 | if (('[' === matchGroups.left_delimiter ? number >= leftNumber : number > leftNumber) && 48 | (']' === matchGroups.right_delimiter ? number <= rightNumber : number < rightNumber)) { 49 | return strtr(matchGroups.message, parameters); 50 | } 51 | } 52 | } 53 | else { 54 | const ruleMatch = part.match(/^\w+:\s*(.*?)$/); 55 | standardRules.push(ruleMatch ? ruleMatch[1] : part); 56 | } 57 | } 58 | const position = getPluralizationRule(number, locale); 59 | if (typeof standardRules[position] === 'undefined') { 60 | if (1 === parts.length && typeof standardRules[0] !== 'undefined') { 61 | return strtr(standardRules[0], parameters); 62 | } 63 | throw new Error(`Unable to choose a translation for "${id}" with locale "${locale}" for value "${number}". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %count% apples").`); 64 | } 65 | return strtr(standardRules[position], parameters); 66 | } 67 | function getPluralizationRule(number, locale) { 68 | number = Math.abs(number); 69 | let _locale = locale; 70 | if (locale === 'pt_BR' || locale === 'en_US_POSIX') { 71 | return 0; 72 | } 73 | _locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf('_')) : _locale; 74 | switch (_locale) { 75 | case 'af': 76 | case 'bn': 77 | case 'bg': 78 | case 'ca': 79 | case 'da': 80 | case 'de': 81 | case 'el': 82 | case 'en': 83 | case 'en_US_POSIX': 84 | case 'eo': 85 | case 'es': 86 | case 'et': 87 | case 'eu': 88 | case 'fa': 89 | case 'fi': 90 | case 'fo': 91 | case 'fur': 92 | case 'fy': 93 | case 'gl': 94 | case 'gu': 95 | case 'ha': 96 | case 'he': 97 | case 'hu': 98 | case 'is': 99 | case 'it': 100 | case 'ku': 101 | case 'lb': 102 | case 'ml': 103 | case 'mn': 104 | case 'mr': 105 | case 'nah': 106 | case 'nb': 107 | case 'ne': 108 | case 'nl': 109 | case 'nn': 110 | case 'no': 111 | case 'oc': 112 | case 'om': 113 | case 'or': 114 | case 'pa': 115 | case 'pap': 116 | case 'ps': 117 | case 'pt': 118 | case 'so': 119 | case 'sq': 120 | case 'sv': 121 | case 'sw': 122 | case 'ta': 123 | case 'te': 124 | case 'tk': 125 | case 'ur': 126 | case 'zu': 127 | return 1 === number ? 0 : 1; 128 | case 'am': 129 | case 'bh': 130 | case 'fil': 131 | case 'fr': 132 | case 'gun': 133 | case 'hi': 134 | case 'hy': 135 | case 'ln': 136 | case 'mg': 137 | case 'nso': 138 | case 'pt_BR': 139 | case 'ti': 140 | case 'wa': 141 | return number < 2 ? 0 : 1; 142 | case 'be': 143 | case 'bs': 144 | case 'hr': 145 | case 'ru': 146 | case 'sh': 147 | case 'sr': 148 | case 'uk': 149 | return 1 === number % 10 && 11 !== number % 100 150 | ? 0 151 | : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) 152 | ? 1 153 | : 2; 154 | case 'cs': 155 | case 'sk': 156 | return 1 === number ? 0 : number >= 2 && number <= 4 ? 1 : 2; 157 | case 'ga': 158 | return 1 === number ? 0 : 2 === number ? 1 : 2; 159 | case 'lt': 160 | return 1 === number % 10 && 11 !== number % 100 161 | ? 0 162 | : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) 163 | ? 1 164 | : 2; 165 | case 'sl': 166 | return 1 === number % 100 ? 0 : 2 === number % 100 ? 1 : 3 === number % 100 || 4 === number % 100 ? 2 : 3; 167 | case 'mk': 168 | return 1 === number % 10 ? 0 : 1; 169 | case 'mt': 170 | return 1 === number 171 | ? 0 172 | : 0 === number || (number % 100 > 1 && number % 100 < 11) 173 | ? 1 174 | : number % 100 > 10 && number % 100 < 20 175 | ? 2 176 | : 3; 177 | case 'lv': 178 | return 0 === number ? 0 : 1 === number % 10 && 11 !== number % 100 ? 1 : 2; 179 | case 'pl': 180 | return 1 === number 181 | ? 0 182 | : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) 183 | ? 1 184 | : 2; 185 | case 'cy': 186 | return 1 === number ? 0 : 2 === number ? 1 : 8 === number || 11 === number ? 2 : 3; 187 | case 'ro': 188 | return 1 === number ? 0 : 0 === number || (number % 100 > 0 && number % 100 < 20) ? 1 : 2; 189 | case 'ar': 190 | return 0 === number 191 | ? 0 192 | : 1 === number 193 | ? 1 194 | : 2 === number 195 | ? 2 196 | : number % 100 >= 3 && number % 100 <= 10 197 | ? 3 198 | : number % 100 >= 11 && number % 100 <= 99 199 | ? 4 200 | : 5; 201 | default: 202 | return 0; 203 | } 204 | } 205 | 206 | function formatIntl(id, parameters, locale) { 207 | if (id === '') { 208 | return ''; 209 | } 210 | const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); 211 | parameters = { ...parameters }; 212 | Object.entries(parameters).forEach(([key, value]) => { 213 | if (key.includes('%') || key.includes('{')) { 214 | delete parameters[key]; 215 | parameters[key.replace(/[%{} ]/g, '').trim()] = value; 216 | } 217 | }); 218 | return intlMessage.format(parameters); 219 | } 220 | 221 | let _locale = null; 222 | let _localeFallbacks = {}; 223 | let _throwWhenNotFound = false; 224 | function setLocale(locale) { 225 | _locale = locale; 226 | } 227 | function getLocale() { 228 | return (_locale || 229 | document.documentElement.getAttribute('data-symfony-ux-translator-locale') || 230 | (document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || 231 | 'en'); 232 | } 233 | function throwWhenNotFound(enabled) { 234 | _throwWhenNotFound = enabled; 235 | } 236 | function setLocaleFallbacks(localeFallbacks) { 237 | _localeFallbacks = localeFallbacks; 238 | } 239 | function getLocaleFallbacks() { 240 | return _localeFallbacks; 241 | } 242 | function trans(message, parameters = {}, domain = 'messages', locale = null) { 243 | if (typeof domain === 'undefined') { 244 | domain = 'messages'; 245 | } 246 | if (typeof locale === 'undefined' || null === locale) { 247 | locale = getLocale(); 248 | } 249 | if (typeof message.translations === 'undefined') { 250 | return message.id; 251 | } 252 | const localesFallbacks = getLocaleFallbacks(); 253 | const translationsIntl = message.translations[`${domain}+intl-icu`]; 254 | if (typeof translationsIntl !== 'undefined') { 255 | while (typeof translationsIntl[locale] === 'undefined') { 256 | locale = localesFallbacks[locale]; 257 | if (!locale) { 258 | break; 259 | } 260 | } 261 | if (locale) { 262 | return formatIntl(translationsIntl[locale], parameters, locale); 263 | } 264 | } 265 | const translations = message.translations[domain]; 266 | if (typeof translations !== 'undefined') { 267 | while (typeof translations[locale] === 'undefined') { 268 | locale = localesFallbacks[locale]; 269 | if (!locale) { 270 | break; 271 | } 272 | } 273 | if (locale) { 274 | return format(translations[locale], parameters, locale); 275 | } 276 | } 277 | if (_throwWhenNotFound) { 278 | throw new Error(`No translation message found with id "${message.id}".`); 279 | } 280 | return message.id; 281 | } 282 | 283 | export { getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, throwWhenNotFound, trans }; 284 | -------------------------------------------------------------------------------- /assets/dist/utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare function strtr(string: string, replacePairs: Record): string; 2 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-translator", 3 | "description": "Symfony Translator for JavaScript", 4 | "license": "MIT", 5 | "version": "2.26.1", 6 | "keywords": [ 7 | "symfony-ux" 8 | ], 9 | "homepage": "https://ux.symfony.com/translator", 10 | "repository": "https://github.com/symfony/ux-translator", 11 | "type": "module", 12 | "files": [ 13 | "dist" 14 | ], 15 | "main": "dist/translator_controller.js", 16 | "types": "dist/translator_controller.d.ts", 17 | "scripts": { 18 | "build": "node ../../../bin/build_package.js .", 19 | "watch": "node ../../../bin/build_package.js . --watch", 20 | "test": "../../../bin/test_package.sh .", 21 | "check": "biome check", 22 | "ci": "biome ci" 23 | }, 24 | "symfony": { 25 | "importmap": { 26 | "intl-messageformat": "^10.5.11", 27 | "@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js", 28 | "@app/translations": "path:var/translations/index.js", 29 | "@app/translations/configuration": "path:var/translations/configuration.js" 30 | } 31 | }, 32 | "peerDependencies": { 33 | "intl-messageformat": "^10.5.11" 34 | }, 35 | "peerDependenciesMeta": { 36 | "intl-messageformat": { 37 | "optional": false 38 | } 39 | }, 40 | "devDependencies": { 41 | "intl-messageformat": "^10.5.11" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/ux-translator", 3 | "type": "symfony-bundle", 4 | "description": "Exposes Symfony Translations directly to JavaScript.", 5 | "keywords": [ 6 | "symfony-ux" 7 | ], 8 | "homepage": "https://symfony.com", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Hugo Alliaume", 13 | "email": "hugo@alliau.me" 14 | }, 15 | { 16 | "name": "Symfony Community", 17 | "homepage": "https://symfony.com/contributors" 18 | } 19 | ], 20 | "autoload": { 21 | "psr-4": { 22 | "Symfony\\UX\\Translator\\": "src/" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Symfony\\UX\\Translator\\Tests\\": "tests/" 28 | } 29 | }, 30 | "require": { 31 | "php": ">=8.1", 32 | "symfony/console": "^5.4|^6.0|^7.0", 33 | "symfony/filesystem": "^5.4|^6.0|^7.0", 34 | "symfony/string": "^5.4|^6.0|^7.0", 35 | "symfony/translation": "^5.4|^6.0|^7.0" 36 | }, 37 | "require-dev": { 38 | "symfony/framework-bundle": "^5.4|^6.0|^7.0", 39 | "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", 40 | "symfony/var-dumper": "^5.4|^6.0|^7.0" 41 | }, 42 | "extra": { 43 | "thanks": { 44 | "name": "symfony/ux", 45 | "url": "https://github.com/symfony/ux" 46 | } 47 | }, 48 | "minimum-stability": "dev" 49 | } 50 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer; 15 | use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor; 16 | use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; 17 | use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; 18 | use Symfony\UX\Translator\TranslationsDumper; 19 | 20 | /* 21 | * @author Hugo Alliaume 22 | */ 23 | return static function (ContainerConfigurator $container): void { 24 | $container->services() 25 | ->set('ux.translator.cache_warmer.translations_cache_warmer', TranslationsCacheWarmer::class) 26 | ->args([ 27 | service('translator'), 28 | service('ux.translator.translations_dumper'), 29 | ]) 30 | ->tag('kernel.cache_warmer') 31 | 32 | ->set('ux.translator.translations_dumper', TranslationsDumper::class) 33 | ->args([ 34 | null, // Dump directory 35 | service('ux.translator.message_parameters.extractor.message_parameters_extractor'), 36 | service('ux.translator.message_parameters.extractor.intl_message_parameters_extractor'), 37 | service('ux.translator.message_parameters.printer.typescript_message_parameters_printer'), 38 | service('filesystem'), 39 | ]) 40 | 41 | ->set('ux.translator.message_parameters.extractor.message_parameters_extractor', MessageParametersExtractor::class) 42 | 43 | ->set('ux.translator.message_parameters.extractor.intl_message_parameters_extractor', IntlMessageParametersExtractor::class) 44 | 45 | ->set('ux.translator.message_parameters.printer.typescript_message_parameters_printer', TypeScriptMessageParametersPrinter::class) 46 | ; 47 | }; 48 | -------------------------------------------------------------------------------- /src/CacheWarmer/TranslationsCacheWarmer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\CacheWarmer; 13 | 14 | use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; 15 | use Symfony\Component\Translation\TranslatorBagInterface; 16 | use Symfony\UX\Translator\TranslationsDumper; 17 | 18 | /** 19 | * @author Hugo Alliaume 20 | * 21 | * @experimental 22 | */ 23 | class TranslationsCacheWarmer implements CacheWarmerInterface 24 | { 25 | public function __construct( 26 | private TranslatorBagInterface $translatorBag, 27 | private TranslationsDumper $translationsDumper, 28 | ) { 29 | } 30 | 31 | public function isOptional(): bool 32 | { 33 | return true; 34 | } 35 | 36 | public function warmUp(string $cacheDir, ?string $buildDir = null): array 37 | { 38 | $this->translationsDumper->dump( 39 | ...$this->translatorBag->getCatalogues() 40 | ); 41 | 42 | // No need to preload anything 43 | return []; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\DependencyInjection; 13 | 14 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 15 | use Symfony\Component\Config\Definition\ConfigurationInterface; 16 | use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; 17 | 18 | /** 19 | * @author Hugo Alliaume 20 | * 21 | * @experimental 22 | */ 23 | class Configuration implements ConfigurationInterface 24 | { 25 | public function getConfigTreeBuilder(): TreeBuilder 26 | { 27 | $treeBuilder = new TreeBuilder('ux_translator'); 28 | $rootNode = $treeBuilder->getRootNode(); 29 | $rootNode 30 | ->children() 31 | ->scalarNode('dump_directory')->defaultValue('%kernel.project_dir%/var/translations')->end() 32 | ->arrayNode('domains') 33 | ->info('List of domains to include/exclude from the generated translations. Prefix with a `!` to exclude a domain.') 34 | ->children() 35 | ->scalarNode('type') 36 | ->validate() 37 | ->ifNotInArray(['inclusive', 'exclusive']) 38 | ->thenInvalid('The type of domains has to be inclusive or exclusive') 39 | ->end() 40 | ->end() 41 | ->arrayNode('elements') 42 | ->scalarPrototype()->end() 43 | ->end() 44 | ->end() 45 | ->canBeUnset() 46 | ->beforeNormalization() 47 | ->ifString() 48 | ->then(fn ($v) => ['elements' => [$v]]) 49 | ->end() 50 | ->beforeNormalization() 51 | ->ifTrue(function ($v) { return \is_array($v) && is_numeric(key($v)); }) 52 | ->then(function ($v) { return ['elements' => $v]; }) 53 | ->end() 54 | ->validate() 55 | ->always(function ($v) { 56 | $isExclusive = null; 57 | $elements = []; 58 | if (isset($v['type'])) { 59 | $isExclusive = 'exclusive' === $v['type']; 60 | } 61 | foreach ($v['elements'] as $domain) { 62 | if (str_starts_with($domain, '!')) { 63 | if (false === $isExclusive) { 64 | throw new InvalidConfigurationException('You cannot mix included and excluded domains.'); 65 | } 66 | $isExclusive = true; 67 | $elements[] = substr($domain, 1); 68 | } else { 69 | if (true === $isExclusive) { 70 | throw new InvalidConfigurationException('You cannot mix included and excluded domains.'); 71 | } 72 | $isExclusive = false; 73 | $elements[] = $domain; 74 | } 75 | } 76 | 77 | if (!\count($elements)) { 78 | return null; 79 | } 80 | 81 | return ['type' => $isExclusive ? 'exclusive' : 'inclusive', 'elements' => array_unique($elements)]; 82 | }) 83 | ->end() 84 | ->end() 85 | ->end() 86 | ; 87 | 88 | return $treeBuilder; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/DependencyInjection/UxTranslatorExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\DependencyInjection; 13 | 14 | use Symfony\Component\AssetMapper\AssetMapperInterface; 15 | use Symfony\Component\Config\FileLocator; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; 18 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 19 | use Symfony\Component\HttpKernel\DependencyInjection\Extension; 20 | 21 | /** 22 | * @author Hugo Alliaume 23 | * 24 | * @internal 25 | * 26 | * @experimental 27 | */ 28 | class UxTranslatorExtension extends Extension implements PrependExtensionInterface 29 | { 30 | public function load(array $configs, ContainerBuilder $container) 31 | { 32 | $configuration = new Configuration(); 33 | $config = $this->processConfiguration($configuration, $configs); 34 | 35 | $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); 36 | $loader->load('services.php'); 37 | 38 | $dumperDefinition = $container->getDefinition('ux.translator.translations_dumper'); 39 | $dumperDefinition->setArgument(0, $config['dump_directory']); 40 | 41 | if (isset($config['domains'])) { 42 | $method = 'inclusive' === $config['domains']['type'] ? 'addIncludedDomain' : 'addExcludedDomain'; 43 | foreach ($config['domains']['elements'] as $domainName) { 44 | $dumperDefinition->addMethodCall($method, [$domainName]); 45 | } 46 | } 47 | } 48 | 49 | public function prepend(ContainerBuilder $container) 50 | { 51 | if (!$this->isAssetMapperAvailable($container)) { 52 | return; 53 | } 54 | 55 | $container->prependExtensionConfig('framework', [ 56 | 'asset_mapper' => [ 57 | 'paths' => [ 58 | __DIR__.'/../../assets/dist' => '@symfony/ux-translator', 59 | '%kernel.project_dir%/var/translations' => 'var/translations', 60 | ], 61 | ], 62 | ]); 63 | } 64 | 65 | private function isAssetMapperAvailable(ContainerBuilder $container): bool 66 | { 67 | if (!interface_exists(AssetMapperInterface::class)) { 68 | return false; 69 | } 70 | 71 | // check that FrameworkBundle 6.3 or higher is installed 72 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); 73 | if (!isset($bundlesMetadata['FrameworkBundle'])) { 74 | return false; 75 | } 76 | 77 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Intl/ErrorKind.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | /** 15 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/error.ts#L9-L77. 16 | * 17 | * @internal 18 | */ 19 | final class ErrorKind 20 | { 21 | /** Argument is unclosed (e.g. `{0`) */ 22 | public const EXPECT_ARGUMENT_CLOSING_BRACE = 'EXPECT_ARGUMENT_CLOSING_BRACE'; 23 | /** Argument is empty (e.g. `{}`). */ 24 | public const EMPTY_ARGUMENT = 'EMPTY_ARGUMENT'; 25 | /** Argument is malformed (e.g. `{foo!}``) */ 26 | public const MALFORMED_ARGUMENT = 'MALFORMED_ARGUMENT'; 27 | /** Expect an argument type (e.g. `{foo,}`) */ 28 | public const EXPECT_ARGUMENT_TYPE = 'EXPECT_ARGUMENT_TYPE'; 29 | /** Unsupported argument type (e.g. `{foo,foo}`) */ 30 | public const INVALID_ARGUMENT_TYPE = 'INVALID_ARGUMENT_TYPE'; 31 | /** Expect an argument style (e.g. `{foo, number, }`) */ 32 | public const EXPECT_ARGUMENT_STYLE = 'EXPECT_ARGUMENT_STYLE'; 33 | /** The number skeleton is invalid. */ 34 | public const INVALID_NUMBER_SKELETON = 'INVALID_NUMBER_SKELETON'; 35 | /** The date time skeleton is invalid. */ 36 | public const INVALID_DATE_TIME_SKELETON = 'INVALID_DATE_TIME_SKELETON'; 37 | /** Exepct a number skeleton following the `::` (e.g. `{foo, number, ::}`) */ 38 | public const EXPECT_NUMBER_SKELETON = 'EXPECT_NUMBER_SKELETON'; 39 | /** Exepct a date time skeleton following the `::` (e.g. `{foo, date, ::}`) */ 40 | public const EXPECT_DATE_TIME_SKELETON = 'EXPECT_DATE_TIME_SKELETON'; 41 | 42 | /** Unmatched apostrophes in the argument style (e.g. `{foo, number, 'test`) */ 43 | public const UNCLOSED_QUOTE_IN_ARGUMENT_STYLE = 'UNCLOSED_QUOTE_IN_ARGUMENT_STYLE'; 44 | 45 | /** Missing select argument options (e.g. `{foo, select}`) */ 46 | public const EXPECT_SELECT_ARGUMENT_OPTIONS = 'EXPECT_SELECT_ARGUMENT_OPTIONS'; 47 | 48 | /** Expecting an offset value in `plural` or `selectordinal` argument (e.g `{foo, plural, offset}`) */ 49 | public const EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE = 'EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE'; 50 | 51 | /** Offset value in `plural` or `selectordinal` is invalid (e.g. `{foo, plural, offset: x}`) */ 52 | public const INVALID_PLURAL_ARGUMENT_OFFSET_VALUE = 'INVALID_PLURAL_ARGUMENT_OFFSET_VALUE'; 53 | 54 | /** Expecting a selector in `select` argument (e.g `{foo, select}`) */ 55 | public const EXPECT_SELECT_ARGUMENT_SELECTOR = 'EXPECT_SELECT_ARGUMENT_SELECTOR'; 56 | 57 | /** Expecting a selector in `plural` or `selectordinal` argument (e.g `{foo, plural}`) */ 58 | public const EXPECT_PLURAL_ARGUMENT_SELECTOR = 'EXPECT_PLURAL_ARGUMENT_SELECTOR'; 59 | 60 | /** Expecting a message fragment after the `select` selector (e.g. `{foo, select, apple}`) */ 61 | public const EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT = 'EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT'; 62 | 63 | /** 64 | * Expecting a message fragment after the `plural` or `selectordinal` selector 65 | * (e.g. `{foo, plural, one}`). 66 | */ 67 | public const EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT = 'EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT'; 68 | 69 | /** Selector in `plural` or `selectordinal` is malformed (e.g. `{foo, plural, =x {#}}`) */ 70 | public const INVALID_PLURAL_ARGUMENT_SELECTOR = 'INVALID_PLURAL_ARGUMENT_SELECTOR'; 71 | 72 | /** 73 | * Duplicate selectors in `plural` or `selectordinal` argument. 74 | * (e.g. {foo, plural, one {#} one {#}}). 75 | */ 76 | public const DUPLICATE_PLURAL_ARGUMENT_SELECTOR = 'DUPLICATE_PLURAL_ARGUMENT_SELECTOR'; 77 | 78 | /** Duplicate selectors in `select` argument. 79 | * (e.g. {foo, select, apple {apple} apple {apple}}). 80 | */ 81 | public const DUPLICATE_SELECT_ARGUMENT_SELECTOR = 'DUPLICATE_SELECT_ARGUMENT_SELECTOR'; 82 | 83 | /** Plural or select argument option must have `other` clause. */ 84 | public const MISSING_OTHER_CLAUSE = 'MISSING_OTHER_CLAUSE'; 85 | 86 | /** The tag is malformed. (e.g. `foo) */ 87 | public const INVALID_TAG = 'INVALID_TAG'; 88 | 89 | /** The tag name is invalid. (e.g. `<123>foo`) */ 90 | public const INVALID_TAG_NAME = 'INVALID_TAG_NAME'; 91 | 92 | /** The closing tag does not match the opening tag. (e.g. `foo`) */ 93 | public const UNMATCHED_CLOSING_TAG = 'UNMATCHED_CLOSING_TAG'; 94 | 95 | /** The opening tag has unmatched closing tag. (e.g. `foo`) */ 96 | public const UNCLOSED_TAG = 'UNCLOSED_TAG'; 97 | 98 | private function __construct() 99 | { 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Intl/IntlMessageParser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | use Symfony\Component\String\AbstractString; 15 | 16 | use function Symfony\Component\String\s; 17 | 18 | /** 19 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/parser.ts. 20 | * 21 | * @internal 22 | */ 23 | final class IntlMessageParser 24 | { 25 | private readonly AbstractString $message; 26 | // Minor optimization, this avoid a lot of calls to `$this->message->length()` 27 | private readonly int $messageLength; 28 | 29 | private Position $position; 30 | private bool $ignoreTag; 31 | private bool $requiresOtherClause; 32 | 33 | public function __construct( 34 | string $message, 35 | ) { 36 | $this->message = s($message); 37 | $this->messageLength = $this->message->length(); 38 | $this->position = new Position(0, 1, 1); 39 | $this->ignoreTag = true; 40 | $this->requiresOtherClause = true; 41 | } 42 | 43 | /** 44 | * @throws \Exception 45 | */ 46 | public function parse(): array 47 | { 48 | return $this->parseMessage(0, '', false); 49 | } 50 | 51 | /** 52 | * @throws \Exception 53 | */ 54 | private function parseMessage(int $nestingLevel, mixed $parentArgType, bool $expectingCloseTag): array 55 | { 56 | $elements = []; 57 | 58 | while (!$this->isEOF()) { 59 | $char = $this->char(); 60 | if (123 === $char /* `{` */) { 61 | $result = $this->parseArgument($nestingLevel, $expectingCloseTag); 62 | if ($result['err']) { 63 | return $result; 64 | } 65 | $elements[] = $result['val']; 66 | } elseif (125 === $char /* `}` */ && $nestingLevel > 0) { 67 | break; 68 | } elseif ( 69 | 35 === $char /* `#` */ 70 | && ('plural' === $parentArgType || 'selectordinal' === $parentArgType) 71 | ) { 72 | $position = clone $this->position; 73 | $this->bump(); 74 | $elements[] = [ 75 | 'type' => 'pound', 76 | 'location' => new Location($position, clone $this->position), 77 | ]; 78 | } elseif ( 79 | 60 === $char /* `<` */ 80 | && !$this->ignoreTag 81 | && 47 === $this->peek() // char code for '/' 82 | ) { 83 | if ($expectingCloseTag) { 84 | break; 85 | } else { 86 | return $this->error( 87 | ErrorKind::UNMATCHED_CLOSING_TAG, 88 | new Location(clone $this->position, clone $this->position) 89 | ); 90 | } 91 | } elseif ( 92 | 60 === $char /* `<` */ 93 | && !$this->ignoreTag 94 | && Utils::isAlpha($this->peek() || 0) 95 | ) { 96 | $result = $this->parseTag($nestingLevel, $parentArgType); 97 | if ($result['err']) { 98 | return $result; 99 | } 100 | $elements[] = $result['val']; 101 | } else { 102 | $result = $this->parseLiteral($nestingLevel, $parentArgType); 103 | if ($result['err']) { 104 | return $result; 105 | } 106 | $elements[] = $result['val']; 107 | } 108 | } 109 | 110 | return [ 111 | 'val' => $elements, 112 | 'err' => null, 113 | ]; 114 | } 115 | 116 | /** 117 | * This method assumes that the caller has peeked ahead for the first tag character. 118 | */ 119 | private function parseTagName(): string 120 | { 121 | $startOffset = $this->position->offset; 122 | 123 | $this->bump(); // the first tag name character 124 | while (!$this->isEOF() && Utils::isPotentialElementNameChar($this->char())) { 125 | $this->bump(); 126 | } 127 | 128 | return $this->message->slice($startOffset, $this->position->offset - $startOffset)->toString(); 129 | } 130 | 131 | /** 132 | * @return array{ val: array{ type: Type::LITERAL, value: string, location: Location }, err: null } 133 | */ 134 | private function parseLiteral(int $nestingLevel, string $parentArgType): array 135 | { 136 | $start = clone $this->position; 137 | 138 | $value = ''; 139 | while (true) { 140 | $parseQuoteResult = $this->tryParseQuote($parentArgType); 141 | if ($parseQuoteResult) { 142 | $value .= $parseQuoteResult; 143 | continue; 144 | } 145 | 146 | $parseUnquotedResult = $this->tryParseUnquoted($nestingLevel, $parentArgType); 147 | if ($parseUnquotedResult) { 148 | $value .= $parseUnquotedResult; 149 | continue; 150 | } 151 | 152 | $parseLeftAngleResult = $this->tryParseLeftAngleBracket(); 153 | if ($parseLeftAngleResult) { 154 | $value .= $parseLeftAngleResult; 155 | continue; 156 | } 157 | 158 | break; 159 | } 160 | 161 | $location = new Location($start, clone $this->position); 162 | 163 | return [ 164 | 'val' => [ 165 | 'type' => Type::LITERAL, 166 | 'value' => $value, 167 | 'location' => $location, 168 | ], 169 | 'err' => null, 170 | ]; 171 | } 172 | 173 | private function tryParseLeftAngleBracket(): ?string 174 | { 175 | if ( 176 | !$this->isEOF() 177 | && 60 === $this->char() /* `<` */ 178 | && ($this->ignoreTag 179 | // If at the opening tag or closing tag position, bail. 180 | || !Utils::isAlphaOrSlash($this->peek() || 0)) 181 | ) { 182 | $this->bump(); // `<` 183 | 184 | return '<'; 185 | } 186 | 187 | return null; 188 | } 189 | 190 | /** 191 | * Starting with ICU 4.8, an ASCII apostrophe only starts quoted text if it immediately precedes 192 | * a character that requires quoting (that is, "only where needed"), and works the same in 193 | * nested messages as on the top level of the pattern. The new behavior is otherwise compatible. 194 | */ 195 | private function tryParseQuote(string $parentArgType): ?string 196 | { 197 | if ($this->isEOF() || 39 !== $this->char() /* `'` */) { 198 | return null; 199 | } 200 | 201 | // Parse escaped char following the apostrophe, or early return if there is no escaped char. 202 | // Check if is valid escaped character 203 | switch ($this->peek()) { 204 | case 39 /* `'` */ : 205 | // double quote, should return as a single quote. 206 | $this->bump(); 207 | 208 | $this->bump(); 209 | 210 | return "'"; 211 | // '{', '<', '>', '}' 212 | case 123: 213 | case 60: 214 | case 62: 215 | case 125: 216 | break; 217 | case 35: // '#' 218 | if ('plural' === $parentArgType || 'selectordinal' === $parentArgType) { 219 | break; 220 | } 221 | 222 | return null; 223 | default: 224 | return null; 225 | } 226 | 227 | $this->bump(); // apostrophe 228 | $codePoints = [$this->char()]; // escaped char 229 | $this->bump(); 230 | 231 | // read chars until the optional closing apostrophe is found 232 | while (!$this->isEOF()) { 233 | $ch = $this->char(); 234 | if (39 === $ch /* `'` */) { 235 | if (39 === $this->peek() /* `'` */) { 236 | $codePoints[] = 39; 237 | // Bump one more time because we need to skip 2 characters. 238 | $this->bump(); 239 | } else { 240 | // Optional closing apostrophe. 241 | $this->bump(); 242 | break; 243 | } 244 | } else { 245 | $codePoints[] = $ch; 246 | } 247 | $this->bump(); 248 | } 249 | 250 | return Utils::fromCodePoint(...$codePoints); 251 | } 252 | 253 | private function tryParseUnquoted( 254 | int $nestingLevel, 255 | string $parentArgType, 256 | ): ?string { 257 | if ($this->isEOF()) { 258 | return null; 259 | } 260 | $ch = $this->char(); 261 | 262 | if ( 263 | 60 === $ch /* `<` */ 264 | || 123 === $ch /* `{` */ 265 | || (35 === $ch /* `#` */ 266 | && ('plural' === $parentArgType || 'selectordinal' === $parentArgType)) 267 | || (125 === $ch /* `}` */ && $nestingLevel > 0) 268 | ) { 269 | return null; 270 | } else { 271 | $this->bump(); 272 | 273 | return Utils::fromCodePoint($ch); 274 | } 275 | } 276 | 277 | /** 278 | * @return Result 279 | */ 280 | private function parseArgument( 281 | int $nestingLevel, 282 | bool $expectingCloseTag, 283 | ): array { 284 | $openingBracePosition = clone $this->position; 285 | $this->bump(); // `{` 286 | 287 | $this->bumpSpace(); 288 | 289 | if ($this->isEOF()) { 290 | return $this->error( 291 | ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, 292 | new Location($openingBracePosition, clone $this->position) 293 | ); 294 | } 295 | 296 | if (125 === $this->char() /* `}` */) { 297 | $this->bump(); 298 | 299 | return $this->error( 300 | ErrorKind::EMPTY_ARGUMENT, 301 | new Location($openingBracePosition, clone $this->position) 302 | ); 303 | } 304 | 305 | // argument name 306 | $value = $this->parseIdentifierIfPossible()['value']; 307 | if (!$value) { 308 | return $this->error( 309 | ErrorKind::MALFORMED_ARGUMENT, 310 | new Location($openingBracePosition, clone $this->position) 311 | ); 312 | } 313 | 314 | $this->bumpSpace(); 315 | 316 | if ($this->isEOF()) { 317 | return $this->error( 318 | ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, 319 | new Location($openingBracePosition, clone $this->position) 320 | ); 321 | } 322 | 323 | switch ($this->char()) { 324 | // Simple argument: `{name}` 325 | case 125 /* `}` */ : 326 | $this->bump(); // `}` 327 | 328 | return [ 329 | 'val' => [ 330 | 'type' => Type::ARGUMENT, 331 | 'value' => $value, 332 | 'location' => new Location($openingBracePosition, clone $this->position), 333 | ], 334 | 'err' => null, 335 | ]; 336 | 337 | // Argument with options: `{name, format, ...}` 338 | case 44 /* `,` */ : 339 | $this->bump(); // `,` 340 | $this->bumpSpace(); 341 | 342 | if ($this->isEOF()) { 343 | return $this->error( 344 | ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, 345 | new Location($openingBracePosition, clone $this->position) 346 | ); 347 | } 348 | 349 | return $this->parseArgumentOptions( 350 | $nestingLevel, 351 | $expectingCloseTag, 352 | $value, 353 | $openingBracePosition 354 | ); 355 | 356 | default: 357 | return $this->error( 358 | ErrorKind::MALFORMED_ARGUMENT, 359 | new Location($openingBracePosition, clone $this->position) 360 | ); 361 | } 362 | } 363 | 364 | /** 365 | * Advance the parser until the end of the identifier, if it is currently on 366 | * an identifier character. Return an empty string otherwise. 367 | * 368 | * @return array{ value: string, location: Location} 369 | */ 370 | private function parseIdentifierIfPossible(): array 371 | { 372 | $startingPosition = clone $this->position; 373 | 374 | $startOffset = $this->position->offset; 375 | $value = Utils::matchIdentifierAtIndex($this->message, $startOffset); 376 | $endOffset = $startOffset + s($value)->length(); 377 | 378 | $this->bumpTo($endOffset); 379 | 380 | $endPosition = clone $this->position; 381 | $location = new Location($startingPosition, $endPosition); 382 | 383 | return ['value' => $value, 'location' => $location]; 384 | } 385 | 386 | private function parseArgumentOptions( 387 | int $nestingLevel, 388 | bool $expectingCloseTag, 389 | string $value, 390 | Position $openingBracePosition, 391 | ): array { 392 | // Parse this range: 393 | // {name, type, style} 394 | // ^---^ 395 | $typeStartPosition = clone $this->position; 396 | $argType = $this->parseIdentifierIfPossible()['value']; 397 | $typeEndPosition = clone $this->position; 398 | 399 | switch ($argType) { 400 | case '': 401 | // Expecting a style string number, date, time, plural, selectordinal, or select. 402 | return $this->error( 403 | ErrorKind::EXPECT_ARGUMENT_TYPE, 404 | new Location($typeStartPosition, $typeEndPosition) 405 | ); 406 | case 'number': 407 | case 'date': 408 | case 'time': 409 | // Parse this range: 410 | // {name, number, style} 411 | // ^-------^ 412 | $this->bumpSpace(); 413 | /** @var array{style: string, styleLocation: Location}|null */ 414 | $styleAndLocation = null; 415 | 416 | if ($this->bumpIf(',')) { 417 | $this->bumpSpace(); 418 | 419 | $styleStartPosition = clone $this->position; 420 | $result = $this->parseSimpleArgStyleIfPossible(); 421 | if ($result['err']) { 422 | return $result; 423 | } 424 | $style = s($result['val'])->trimEnd(); 425 | 426 | if (0 === $style->length()) { 427 | return $this->error( 428 | ErrorKind::EXPECT_ARGUMENT_STYLE, 429 | new Location(clone $this->position, clone $this->position) 430 | ); 431 | } 432 | 433 | $styleLocation = new Location( 434 | $styleStartPosition, 435 | clone $this->position 436 | ); 437 | $styleAndLocation = [ 438 | 'style' => $style->toString(), 439 | 'styleLocation' => $styleLocation, 440 | ]; 441 | } 442 | 443 | $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); 444 | if ($argCloseResult['err']) { 445 | return $argCloseResult; 446 | } 447 | 448 | $location = new Location( 449 | $openingBracePosition, 450 | clone $this->position 451 | ); 452 | 453 | // Extract style or skeleton 454 | if ($styleAndLocation && ($style = s($styleAndLocation['style'] ?? ''))->startsWith('::')) { 455 | // Skeleton starts with `::`. 456 | $skeleton = $style->slice(2)->trimStart()->toString(); 457 | 458 | if ('number' === $argType) { 459 | $result = $this->parseNumberSkeletonFromString( 460 | $skeleton, 461 | $styleAndLocation['styleLocation'] 462 | ); 463 | if ($result['err']) { 464 | return $result; 465 | } 466 | 467 | return [ 468 | 'val' => [ 469 | 'type' => Type::NUMBER, 470 | 'value' => $value, 471 | 'location' => $location, 472 | 'style' => $result['val'], 473 | ], 474 | 'err' => null, 475 | ]; 476 | } else { 477 | if (0 === s($skeleton)->length()) { 478 | return $this->error(ErrorKind::EXPECT_DATE_TIME_SKELETON, $location); 479 | } 480 | 481 | $dateTimePattern = $skeleton; 482 | 483 | $style = [ 484 | 'type' => SkeletonType::DATE_TIME, 485 | 'pattern' => $dateTimePattern, 486 | 'location' => $styleAndLocation['styleLocation'], 487 | 'parsedOptions' => [], 488 | ]; 489 | 490 | $type = 'date' === $argType ? Type::DATE : Type::TIME; 491 | 492 | return [ 493 | 'val' => [ 494 | 'type' => $type, 495 | 'value' => $value, 496 | 'location' => $location, 497 | 'style' => $style, 498 | ], 499 | 'err' => null, 500 | ]; 501 | } 502 | } 503 | 504 | // Regular style or no style. 505 | return [ 506 | 'val' => [ 507 | 'type' => 'number' === $argType ? Type::NUMBER : ('date' === $argType ? Type::DATE : Type::TIME), 508 | 'value' => $value, 509 | 'location' => $location, 510 | 'style' => $styleAndLocation['style'] ?? null, 511 | ], 512 | 'err' => null, 513 | ]; 514 | 515 | case 'plural': 516 | case 'selectordinal': 517 | case 'select': 518 | // Parse this range: 519 | // {name, plural, options} 520 | // ^---------^ 521 | $typeEndPosition = clone $this->position; 522 | $this->bumpSpace(); 523 | 524 | if (!$this->bumpIf(',')) { 525 | return $this->error( 526 | ErrorKind::EXPECT_SELECT_ARGUMENT_OPTIONS, 527 | new Location($typeEndPosition, clone $typeEndPosition) 528 | ); 529 | } 530 | $this->bumpSpace(); 531 | 532 | // Parse offset: 533 | // {name, plural, offset:1, options} 534 | // ^-----^ 535 | // 536 | // or the first option: 537 | // 538 | // {name, plural, one {...} other {...}} 539 | // ^--^ 540 | $identifierAndLocation = $this->parseIdentifierIfPossible(); 541 | 542 | $pluralOffset = 0; 543 | if ('select' !== $argType && 'offset' === $identifierAndLocation['value']) { 544 | if (!$this->bumpIf(':')) { 545 | return $this->error( 546 | ErrorKind::EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE, 547 | new Location(clone $this->position, clone $this->position) 548 | ); 549 | } 550 | $this->bumpSpace(); 551 | $result = $this->tryParseDecimalInteger( 552 | ErrorKind::EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE, 553 | ErrorKind::INVALID_PLURAL_ARGUMENT_OFFSET_VALUE 554 | ); 555 | if ($result['err']) { 556 | return $result; 557 | } 558 | 559 | // Parse another identifier for option parsing 560 | $this->bumpSpace(); 561 | $identifierAndLocation = $this->parseIdentifierIfPossible(); 562 | 563 | $pluralOffset = $result['val']; 564 | } 565 | 566 | $optionsResult = $this->tryParsePluralOrSelectOptions( 567 | $nestingLevel, 568 | $argType, 569 | $expectingCloseTag, 570 | $identifierAndLocation 571 | ); 572 | if ($optionsResult['err']) { 573 | return $optionsResult; 574 | } 575 | 576 | $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); 577 | if ($argCloseResult['err']) { 578 | return $argCloseResult; 579 | } 580 | 581 | $location = new Location( 582 | $openingBracePosition, 583 | clone $this->position 584 | ); 585 | 586 | if ('select' === $argType) { 587 | return [ 588 | 'val' => [ 589 | 'type' => Type::SELECT, 590 | 'value' => $value, 591 | 'options' => $optionsResult['val'], 592 | 'location' => $location, 593 | ], 594 | 'err' => null, 595 | ]; 596 | } else { 597 | return [ 598 | 'val' => [ 599 | 'type' => Type::PLURAL, 600 | 'value' => $value, 601 | 'offset' => $pluralOffset, 602 | 'options' => $optionsResult['val'], 603 | 'pluralType' => 'plural' === $argType ? 'cardinal' : 'ordinal', 604 | 'location' => $location, 605 | ], 606 | 'err' => null, 607 | ]; 608 | } 609 | 610 | // no break 611 | default: 612 | return $this->error( 613 | ErrorKind::INVALID_ARGUMENT_TYPE, 614 | new Location($typeStartPosition, $typeEndPosition) 615 | ); 616 | } 617 | } 618 | 619 | private function tryParseArgumentClose( 620 | Position $openingBracePosition, 621 | ): array { 622 | // Parse: {value, number, ::currency/GBP } 623 | // 624 | if ($this->isEOF() || 125 !== $this->char() /* `}` */) { 625 | return $this->error( 626 | ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, 627 | new Location($openingBracePosition, clone $this->position) 628 | ); 629 | } 630 | $this->bump(); // `}` 631 | 632 | return ['val' => null, 'err' => null]; 633 | } 634 | 635 | /** 636 | * See: https://github.com/unicode-org/icu/blob/af7ed1f6d2298013dc303628438ec4abe1f16479/icu4c/source/common/messagepattern.cpp#L659. 637 | */ 638 | private function parseSimpleArgStyleIfPossible(): array 639 | { 640 | $nestedBraces = 0; 641 | 642 | $startPosition = clone $this->position; 643 | while (!$this->isEOF()) { 644 | $ch = $this->char(); 645 | switch ($ch) { 646 | case 39 /* `'` */ : 647 | // Treat apostrophe as quoting but include it in the style part. 648 | // Find the end of the quoted literal text. 649 | $this->bump(); 650 | 651 | $apostrophePosition = clone $this->position; 652 | 653 | if (!$this->bumpUntil("'")) { 654 | return $this->error( 655 | ErrorKind::UNCLOSED_QUOTE_IN_ARGUMENT_STYLE, 656 | new Location($apostrophePosition, clone $this->position) 657 | ); 658 | } 659 | $this->bump(); 660 | break; 661 | 662 | case 123 /* `{` */ : 663 | ++$nestedBraces; 664 | $this->bump(); 665 | break; 666 | 667 | case 125 /* `}` */ : 668 | if ($nestedBraces > 0) { 669 | --$nestedBraces; 670 | } else { 671 | return [ 672 | 'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(), 673 | 'err' => null, 674 | ]; 675 | } 676 | break; 677 | 678 | default: 679 | $this->bump(); 680 | break; 681 | } 682 | } 683 | 684 | return [ 685 | 'val' => $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(), 686 | 'err' => null, 687 | ]; 688 | } 689 | 690 | private function parseNumberSkeletonFromString( 691 | string $skeleton, 692 | Location $location, 693 | ) { 694 | $tokens = []; 695 | 696 | return [ 697 | 'val' => [ 698 | 'type' => Type::NUMBER, 699 | 'tokens' => $tokens, 700 | 'location' => $location, 701 | 'parsedOptions' => [], 702 | ], 703 | 'err' => null, 704 | ]; 705 | } 706 | 707 | /** 708 | * @param number nesting_level The current nesting level of messages. 709 | * This can be positive when parsing message fragment in select or plural argument options. 710 | * @param string parent_arg_type The parent argument's type 711 | * @param bool parsed_first_identifier If provided, this is the first identifier-like selector of 712 | * the argument. It is a by-product of a previous parsing attempt. 713 | * @param array{value: string, location: Location} expecting_close_tag If true, this message is directly or indirectly nested inside 714 | * between a pair of opening and closing tags. The nested message will not parse beyond 715 | * the closing tag boundary. 716 | */ 717 | private function tryParsePluralOrSelectOptions( 718 | int $nestingLevel, 719 | string $parentArgType, 720 | bool $expectCloseTag, 721 | array $parsedFirstIdentifier, 722 | ): array { 723 | $hasOtherClause = false; 724 | $options = []; 725 | $parsedSelectors = []; 726 | ['value' => $selector, 'location' => $selectorLocation] = $parsedFirstIdentifier; 727 | 728 | // Parse: 729 | // one {one apple} 730 | // ^--^ 731 | while (true) { 732 | if ('' === $selector) { 733 | $startPosition = clone $this->position; 734 | if ('select' !== $parentArgType && $this->bumpIf('=')) { 735 | // Try parse `={number}` selector 736 | $result = $this->tryParseDecimalInteger( 737 | ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR, 738 | ErrorKind::INVALID_PLURAL_ARGUMENT_SELECTOR 739 | ); 740 | if ($result['err']) { 741 | return $result; 742 | } 743 | $selectorLocation = new Location($startPosition, clone $this->position); 744 | $selector = $this->message->slice($startPosition->offset, $this->position->offset - $startPosition->offset)->toString(); 745 | } else { 746 | break; 747 | } 748 | } 749 | 750 | // Duplicate selector clauses 751 | if (\in_array($selector, $parsedSelectors, true)) { 752 | return $this->error( 753 | 'select' === $parentArgType 754 | ? ErrorKind::DUPLICATE_SELECT_ARGUMENT_SELECTOR 755 | : ErrorKind::DUPLICATE_PLURAL_ARGUMENT_SELECTOR, 756 | $selectorLocation 757 | ); 758 | } 759 | 760 | if ('other' === $selector) { 761 | $hasOtherClause = true; 762 | } 763 | 764 | // Parse: 765 | // one {one apple} 766 | // ^----------^ 767 | $this->bumpSpace(); 768 | $openingBracePosition = clone $this->position; 769 | if (!$this->bumpIf('{')) { 770 | return $this->error( 771 | 'select' === $parentArgType 772 | ? ErrorKind::EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT 773 | : ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT, 774 | new Location(clone $this->position, clone $this->position) 775 | ); 776 | } 777 | 778 | $fragmentResult = $this->parseMessage( 779 | $nestingLevel + 1, 780 | $parentArgType, 781 | $expectCloseTag 782 | ); 783 | if ($fragmentResult['err']) { 784 | return $fragmentResult; 785 | } 786 | $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); 787 | if ($argCloseResult['err']) { 788 | return $argCloseResult; 789 | } 790 | 791 | $options[$selector] = [ 792 | 'value' => $fragmentResult['val'], 793 | 'location' => new Location($openingBracePosition, clone $this->position), 794 | ]; 795 | 796 | // Keep track of the existing selectors 797 | $parsedSelectors[] = $selector; 798 | 799 | // Prep next selector clause. 800 | $this->bumpSpace(); 801 | ['value' => $selector, 'location' => $selectorLocation] = $this->parseIdentifierIfPossible(); 802 | } 803 | 804 | if (0 === \count($options)) { 805 | return $this->error( 806 | 'select' === $parentArgType 807 | ? ErrorKind::EXPECT_SELECT_ARGUMENT_SELECTOR 808 | : ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR, 809 | new Location(clone $this->position, clone $this->position) 810 | ); 811 | } 812 | 813 | if ($this->requiresOtherClause && !$hasOtherClause) { 814 | return $this->error( 815 | ErrorKind::MISSING_OTHER_CLAUSE, 816 | new Location(clone $this->position, clone $this->position) 817 | ); 818 | } 819 | 820 | return [ 821 | 'val' => $options, 822 | 'err' => null, 823 | ]; 824 | } 825 | 826 | /** 827 | * @param ErrorKind::* $expectNumberError 828 | * @param ErrorKind::* $invalidNumberError 829 | */ 830 | private function tryParseDecimalInteger( 831 | string $expectNumberError, 832 | string $invalidNumberError, 833 | ): array { 834 | $sign = 1; 835 | $startingPosition = clone $this->position; 836 | 837 | if ($this->bumpIf('+')) { 838 | // no-op 839 | } elseif ($this->bumpIf('-')) { 840 | $sign = -1; 841 | } 842 | 843 | $hasDigits = false; 844 | $decimal = 0; 845 | while (!$this->isEOF()) { 846 | $ch = $this->char(); 847 | if ($ch >= 48 /* `0` */ && $ch <= 57 /* `9` */) { 848 | $hasDigits = true; 849 | $decimal = $decimal * 10 + ($ch - 48); 850 | $this->bump(); 851 | } else { 852 | break; 853 | } 854 | } 855 | 856 | $location = new Location($startingPosition, clone $this->position); 857 | 858 | if (!$hasDigits) { 859 | return $this->error($expectNumberError, $location); 860 | } 861 | 862 | $decimal *= $sign; 863 | if (!Utils::isSafeInteger($decimal)) { 864 | return $this->error($invalidNumberError, $location); 865 | } 866 | 867 | return [ 868 | 'val' => $decimal, 869 | 'err' => null, 870 | ]; 871 | } 872 | 873 | private function isEOF(): bool 874 | { 875 | return $this->position->offset === $this->messageLength; 876 | } 877 | 878 | /** 879 | * Return the code point at the current position of the parser. 880 | * Throws if the index is out of bound. 881 | * 882 | * @throws \Exception 883 | */ 884 | private function char(): int 885 | { 886 | $offset = $this->position->offset; 887 | if ($offset >= $this->messageLength) { 888 | throw new \OutOfBoundsException(); 889 | } 890 | 891 | $code = $this->message->codePointsAt($offset)[0] ?? null; 892 | if (null === $code) { 893 | throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary."); 894 | } 895 | 896 | return $code; 897 | } 898 | 899 | /** 900 | * @param ErrorKind::* 901 | * 902 | * @return array{ val: null, err: array{ kind: ErrorKind::*, location: Location, message: string } } 903 | */ 904 | private function error(string $kind, Location $location): array 905 | { 906 | return [ 907 | 'val' => null, 908 | 'err' => [ 909 | 'kind' => $kind, 910 | 'location' => $location, 911 | 'message' => $this->message->toString(), 912 | ], 913 | ]; 914 | } 915 | 916 | /** 917 | * Bump the parser to the next UTF-16 code unit. 918 | */ 919 | private function bump(): void 920 | { 921 | if ($this->isEOF()) { 922 | return; 923 | } 924 | $code = $this->char(); 925 | if (10 === $code /* '\n' */) { 926 | ++$this->position->line; 927 | $this->position->column = 1; 928 | ++$this->position->offset; 929 | } else { 930 | ++$this->position->column; 931 | ++$this->position->offset; 932 | } 933 | } 934 | 935 | /** 936 | * If the substring starting at the current position of the parser has 937 | * the given prefix, then bump the parser to the character immediately 938 | * following the prefix and return true. Otherwise, don't bump the parser 939 | * and return false. 940 | */ 941 | private function bumpIf(string $prefix): bool 942 | { 943 | if ($this->message->slice($this->position->offset)->startsWith($prefix)) { 944 | for ($i = 0, $len = \strlen($prefix); $i < $len; ++$i) { 945 | $this->bump(); 946 | } 947 | 948 | return true; 949 | } 950 | 951 | return false; 952 | } 953 | 954 | /** 955 | * Bump the parser until the pattern character is found and return `true`. 956 | * Otherwise bump to the end of the file and return `false`. 957 | */ 958 | private function bumpUntil(string $pattern): bool 959 | { 960 | $index = $this->message->indexOf($pattern, $this->position->offset); 961 | if ($index >= 0) { 962 | $this->bumpTo($index); 963 | 964 | return true; 965 | } else { 966 | $this->bumpTo($this->messageLength); 967 | 968 | return false; 969 | } 970 | } 971 | 972 | /** 973 | * Bump the parser to the target offset. 974 | * If target offset is beyond the end of the input, bump the parser to the end of the input. 975 | * 976 | * @throws \Exception 977 | */ 978 | private function bumpTo(int $targetOffset) 979 | { 980 | if ($this->position->offset > $targetOffset) { 981 | throw new \Exception(\sprintf('targetOffset "%s" must be greater than or equal to the current offset %d', $targetOffset, $this->position->offset)); 982 | } 983 | 984 | $targetOffset = min($targetOffset, $this->messageLength); 985 | while (true) { 986 | $offset = $this->position->offset; 987 | if ($offset === $targetOffset) { 988 | break; 989 | } 990 | if ($offset > $targetOffset) { 991 | throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary."); 992 | } 993 | 994 | $this->bump(); 995 | if ($this->isEOF()) { 996 | break; 997 | } 998 | } 999 | } 1000 | 1001 | /** advance the parser through all whitespace to the next non-whitespace code unit. */ 1002 | private function bumpSpace() 1003 | { 1004 | while (!$this->isEOF() && Utils::isWhiteSpace($this->char())) { 1005 | $this->bump(); 1006 | } 1007 | } 1008 | 1009 | /** 1010 | * Peek at the *next* Unicode codepoint in the input without advancing the parser. 1011 | * If the input has been exhausted, then this returns null. 1012 | */ 1013 | private function peek(): ?int 1014 | { 1015 | if ($this->isEOF()) { 1016 | return null; 1017 | } 1018 | 1019 | $code = $this->char(); 1020 | $nextCodes = $this->message->codePointsAt($this->position->offset + ($code >= 0x10000 ? 2 : 1)); 1021 | 1022 | return $nextCodes[0] ?? null; 1023 | } 1024 | } 1025 | -------------------------------------------------------------------------------- /src/Intl/Location.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | /** 15 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L58-L61. 16 | * 17 | * @internal 18 | */ 19 | final class Location 20 | { 21 | public Position $start; 22 | public Position $end; 23 | 24 | public function __construct(Position $start, Position $end) 25 | { 26 | $this->start = $start; 27 | $this->end = $end; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Intl/Position.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | /** 15 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L53-L57. 16 | * 17 | * @internal 18 | */ 19 | final class Position 20 | { 21 | /** Offset in terms of UTF-16 *code unit*. */ 22 | public int $offset; 23 | public int $line; 24 | /** Column offset in terms of unicode *code point*. */ 25 | public int $column; 26 | 27 | public function __construct(int $offset, int $line, int $column) 28 | { 29 | $this->offset = $offset; 30 | $this->line = $line; 31 | $this->column = $column; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Intl/SkeletonType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | /** 15 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L48-L51. 16 | * 17 | * @internal 18 | */ 19 | final class SkeletonType 20 | { 21 | public const NUMBER = 'number'; 22 | public const DATE_TIME = 'dateTime'; 23 | 24 | private function __construct() 25 | { 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Intl/Type.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | /** 15 | * Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L8-L46. 16 | * 17 | * @internal 18 | */ 19 | final class Type 20 | { 21 | /** 22 | * Raw text. 23 | */ 24 | public const LITERAL = 'literal'; 25 | 26 | /** 27 | * Variable w/o any format, e.g `var` in `this is a {var}`. 28 | */ 29 | public const ARGUMENT = 'argument'; 30 | 31 | /** 32 | * Variable w/ number format. 33 | */ 34 | public const NUMBER = 'number'; 35 | 36 | /** 37 | * Variable w/ date format. 38 | */ 39 | public const DATE = 'date'; 40 | 41 | /** 42 | * Variable w/ time format. 43 | */ 44 | public const TIME = 'time'; 45 | 46 | /** 47 | * Variable w/ select format. 48 | */ 49 | public const SELECT = 'select'; 50 | 51 | /** 52 | * Variable w/ plural format. 53 | */ 54 | public const PLURAL = 'plural'; 55 | 56 | /** 57 | * Only possible within plural argument. 58 | * This is the `#` symbol that will be substituted with the count. 59 | */ 60 | public const POUND = 'pound'; 61 | 62 | /** 63 | * XML-like tag. 64 | */ 65 | public const TAG = 'tag'; 66 | 67 | private function __construct() 68 | { 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Intl/Utils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\Intl; 13 | 14 | use Symfony\Component\String\AbstractString; 15 | 16 | /** 17 | * @internal 18 | */ 19 | final class Utils 20 | { 21 | private function __construct() 22 | { 23 | } 24 | 25 | /** 26 | * This check if codepoint is alphabet (lower & uppercase). 27 | */ 28 | public static function isAlpha(int $codepoint): bool 29 | { 30 | return 31 | ($codepoint >= 97 && $codepoint <= 122) 32 | || ($codepoint >= 65 && $codepoint <= 90) 33 | ; 34 | } 35 | 36 | public static function isAlphaOrSlash(int $codepoint): bool 37 | { 38 | return self::isAlpha($codepoint) || 47 === $codepoint; /* '/' */ 39 | } 40 | 41 | /** See `parseTag` function docs. */ 42 | public static function isPotentialElementNameChar(int $c): bool 43 | { 44 | return 45 | 45 === $c /* '-' */ 46 | || 46 === $c /* '.' */ 47 | || ($c >= 48 && $c <= 57) /* 0..9 */ 48 | || 95 === $c /* '_' */ 49 | || ($c >= 97 && $c <= 122) /* a..z */ 50 | || ($c >= 65 && $c <= 90) /* A..Z */ 51 | || 0xB7 == $c 52 | || ($c >= 0xC0 && $c <= 0xD6) 53 | || ($c >= 0xD8 && $c <= 0xF6) 54 | || ($c >= 0xF8 && $c <= 0x37D) 55 | || ($c >= 0x37F && $c <= 0x1FFF) 56 | || ($c >= 0x200C && $c <= 0x200D) 57 | || ($c >= 0x203F && $c <= 0x2040) 58 | || ($c >= 0x2070 && $c <= 0x218F) 59 | || ($c >= 0x2C00 && $c <= 0x2FEF) 60 | || ($c >= 0x3001 && $c <= 0xD7FF) 61 | || ($c >= 0xF900 && $c <= 0xFDCF) 62 | || ($c >= 0xFDF0 && $c <= 0xFFFD) 63 | || ($c >= 0x10000 && $c <= 0xEFFFF) 64 | ; 65 | } 66 | 67 | /** 68 | * Code point equivalent of regex `\p{White_Space}`. 69 | * From: https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt. 70 | */ 71 | public static function isWhiteSpace(int $c) 72 | { 73 | return 74 | ($c >= 0x0009 && $c <= 0x000D) 75 | || 0x0020 === $c 76 | || 0x0085 === $c 77 | || ($c >= 0x200E && $c <= 0x200F) 78 | || 0x2028 === $c 79 | || 0x2029 === $c 80 | ; 81 | } 82 | 83 | /** 84 | * Code point equivalent of regex `\p{Pattern_Syntax}`. 85 | * See https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt. 86 | */ 87 | public static function isPatternSyntax(int $c): bool 88 | { 89 | return 90 | ($c >= 0x0021 && $c <= 0x0023) 91 | || 0x0024 === $c 92 | || ($c >= 0x0025 && $c <= 0x0027) 93 | || 0x0028 === $c 94 | || 0x0029 === $c 95 | || 0x002A === $c 96 | || 0x002B === $c 97 | || 0x002C === $c 98 | || 0x002D === $c 99 | || ($c >= 0x002E && $c <= 0x002F) 100 | || ($c >= 0x003A && $c <= 0x003B) 101 | || ($c >= 0x003C && $c <= 0x003E) 102 | || ($c >= 0x003F && $c <= 0x0040) 103 | || 0x005B === $c 104 | || 0x005C === $c 105 | || 0x005D === $c 106 | || 0x005E === $c 107 | || 0x0060 === $c 108 | || 0x007B === $c 109 | || 0x007C === $c 110 | || 0x007D === $c 111 | || 0x007E === $c 112 | || 0x00A1 === $c 113 | || ($c >= 0x00A2 && $c <= 0x00A5) 114 | || 0x00A6 === $c 115 | || 0x00A7 === $c 116 | || 0x00A9 === $c 117 | || 0x00AB === $c 118 | || 0x00AC === $c 119 | || 0x00AE === $c 120 | || 0x00B0 === $c 121 | || 0x00B1 === $c 122 | || 0x00B6 === $c 123 | || 0x00BB === $c 124 | || 0x00BF === $c 125 | || 0x00D7 === $c 126 | || 0x00F7 === $c 127 | || ($c >= 0x2010 && $c <= 0x2015) 128 | || ($c >= 0x2016 && $c <= 0x2017) 129 | || 0x2018 === $c 130 | || 0x2019 === $c 131 | || 0x201A === $c 132 | || ($c >= 0x201B && $c <= 0x201C) 133 | || 0x201D === $c 134 | || 0x201E === $c 135 | || 0x201F === $c 136 | || ($c >= 0x2020 && $c <= 0x2027) 137 | || ($c >= 0x2030 && $c <= 0x2038) 138 | || 0x2039 === $c 139 | || 0x203A === $c 140 | || ($c >= 0x203B && $c <= 0x203E) 141 | || ($c >= 0x2041 && $c <= 0x2043) 142 | || 0x2044 === $c 143 | || 0x2045 === $c 144 | || 0x2046 === $c 145 | || ($c >= 0x2047 && $c <= 0x2051) 146 | || 0x2052 === $c 147 | || 0x2053 === $c 148 | || ($c >= 0x2055 && $c <= 0x205E) 149 | || ($c >= 0x2190 && $c <= 0x2194) 150 | || ($c >= 0x2195 && $c <= 0x2199) 151 | || ($c >= 0x219A && $c <= 0x219B) 152 | || ($c >= 0x219C && $c <= 0x219F) 153 | || 0x21A0 === $c 154 | || ($c >= 0x21A1 && $c <= 0x21A2) 155 | || 0x21A3 === $c 156 | || ($c >= 0x21A4 && $c <= 0x21A5) 157 | || 0x21A6 === $c 158 | || ($c >= 0x21A7 && $c <= 0x21AD) 159 | || 0x21AE === $c 160 | || ($c >= 0x21AF && $c <= 0x21CD) 161 | || ($c >= 0x21CE && $c <= 0x21CF) 162 | || ($c >= 0x21D0 && $c <= 0x21D1) 163 | || 0x21D2 === $c 164 | || 0x21D3 === $c 165 | || 0x21D4 === $c 166 | || ($c >= 0x21D5 && $c <= 0x21F3) 167 | || ($c >= 0x21F4 && $c <= 0x22FF) 168 | || ($c >= 0x2300 && $c <= 0x2307) 169 | || 0x2308 === $c 170 | || 0x2309 === $c 171 | || 0x230A === $c 172 | || 0x230B === $c 173 | || ($c >= 0x230C && $c <= 0x231F) 174 | || ($c >= 0x2320 && $c <= 0x2321) 175 | || ($c >= 0x2322 && $c <= 0x2328) 176 | || 0x2329 === $c 177 | || 0x232A === $c 178 | || ($c >= 0x232B && $c <= 0x237B) 179 | || 0x237C === $c 180 | || ($c >= 0x237D && $c <= 0x239A) 181 | || ($c >= 0x239B && $c <= 0x23B3) 182 | || ($c >= 0x23B4 && $c <= 0x23DB) 183 | || ($c >= 0x23DC && $c <= 0x23E1) 184 | || ($c >= 0x23E2 && $c <= 0x2426) 185 | || ($c >= 0x2427 && $c <= 0x243F) 186 | || ($c >= 0x2440 && $c <= 0x244A) 187 | || ($c >= 0x244B && $c <= 0x245F) 188 | || ($c >= 0x2500 && $c <= 0x25B6) 189 | || 0x25B7 === $c 190 | || ($c >= 0x25B8 && $c <= 0x25C0) 191 | || 0x25C1 === $c 192 | || ($c >= 0x25C2 && $c <= 0x25F7) 193 | || ($c >= 0x25F8 && $c <= 0x25FF) 194 | || ($c >= 0x2600 && $c <= 0x266E) 195 | || 0x266F === $c 196 | || ($c >= 0x2670 && $c <= 0x2767) 197 | || 0x2768 === $c 198 | || 0x2769 === $c 199 | || 0x276A === $c 200 | || 0x276B === $c 201 | || 0x276C === $c 202 | || 0x276D === $c 203 | || 0x276E === $c 204 | || 0x276F === $c 205 | || 0x2770 === $c 206 | || 0x2771 === $c 207 | || 0x2772 === $c 208 | || 0x2773 === $c 209 | || 0x2774 === $c 210 | || 0x2775 === $c 211 | || ($c >= 0x2794 && $c <= 0x27BF) 212 | || ($c >= 0x27C0 && $c <= 0x27C4) 213 | || 0x27C5 === $c 214 | || 0x27C6 === $c 215 | || ($c >= 0x27C7 && $c <= 0x27E5) 216 | || 0x27E6 === $c 217 | || 0x27E7 === $c 218 | || 0x27E8 === $c 219 | || 0x27E9 === $c 220 | || 0x27EA === $c 221 | || 0x27EB === $c 222 | || 0x27EC === $c 223 | || 0x27ED === $c 224 | || 0x27EE === $c 225 | || 0x27EF === $c 226 | || ($c >= 0x27F0 && $c <= 0x27FF) 227 | || ($c >= 0x2800 && $c <= 0x28FF) 228 | || ($c >= 0x2900 && $c <= 0x2982) 229 | || 0x2983 === $c 230 | || 0x2984 === $c 231 | || 0x2985 === $c 232 | || 0x2986 === $c 233 | || 0x2987 === $c 234 | || 0x2988 === $c 235 | || 0x2989 === $c 236 | || 0x298A === $c 237 | || 0x298B === $c 238 | || 0x298C === $c 239 | || 0x298D === $c 240 | || 0x298E === $c 241 | || 0x298F === $c 242 | || 0x2990 === $c 243 | || 0x2991 === $c 244 | || 0x2992 === $c 245 | || 0x2993 === $c 246 | || 0x2994 === $c 247 | || 0x2995 === $c 248 | || 0x2996 === $c 249 | || 0x2997 === $c 250 | || 0x2998 === $c 251 | || ($c >= 0x2999 && $c <= 0x29D7) 252 | || 0x29D8 === $c 253 | || 0x29D9 === $c 254 | || 0x29DA === $c 255 | || 0x29DB === $c 256 | || ($c >= 0x29DC && $c <= 0x29FB) 257 | || 0x29FC === $c 258 | || 0x29FD === $c 259 | || ($c >= 0x29FE && $c <= 0x2AFF) 260 | || ($c >= 0x2B00 && $c <= 0x2B2F) 261 | || ($c >= 0x2B30 && $c <= 0x2B44) 262 | || ($c >= 0x2B45 && $c <= 0x2B46) 263 | || ($c >= 0x2B47 && $c <= 0x2B4C) 264 | || ($c >= 0x2B4D && $c <= 0x2B73) 265 | || ($c >= 0x2B74 && $c <= 0x2B75) 266 | || ($c >= 0x2B76 && $c <= 0x2B95) 267 | || 0x2B96 === $c 268 | || ($c >= 0x2B97 && $c <= 0x2BFF) 269 | || ($c >= 0x2E00 && $c <= 0x2E01) 270 | || 0x2E02 === $c 271 | || 0x2E03 === $c 272 | || 0x2E04 === $c 273 | || 0x2E05 === $c 274 | || ($c >= 0x2E06 && $c <= 0x2E08) 275 | || 0x2E09 === $c 276 | || 0x2E0A === $c 277 | || 0x2E0B === $c 278 | || 0x2E0C === $c 279 | || 0x2E0D === $c 280 | || ($c >= 0x2E0E && $c <= 0x2E16) 281 | || 0x2E17 === $c 282 | || ($c >= 0x2E18 && $c <= 0x2E19) 283 | || 0x2E1A === $c 284 | || 0x2E1B === $c 285 | || 0x2E1C === $c 286 | || 0x2E1D === $c 287 | || ($c >= 0x2E1E && $c <= 0x2E1F) 288 | || 0x2E20 === $c 289 | || 0x2E21 === $c 290 | || 0x2E22 === $c 291 | || 0x2E23 === $c 292 | || 0x2E24 === $c 293 | || 0x2E25 === $c 294 | || 0x2E26 === $c 295 | || 0x2E27 === $c 296 | || 0x2E28 === $c 297 | || 0x2E29 === $c 298 | || ($c >= 0x2E2A && $c <= 0x2E2E) 299 | || 0x2E2F === $c 300 | || ($c >= 0x2E30 && $c <= 0x2E39) 301 | || ($c >= 0x2E3A && $c <= 0x2E3B) 302 | || ($c >= 0x2E3C && $c <= 0x2E3F) 303 | || 0x2E40 === $c 304 | || 0x2E41 === $c 305 | || 0x2E42 === $c 306 | || ($c >= 0x2E43 && $c <= 0x2E4F) 307 | || ($c >= 0x2E50 && $c <= 0x2E51) 308 | || 0x2E52 === $c 309 | || ($c >= 0x2E53 && $c <= 0x2E7F) 310 | || ($c >= 0x3001 && $c <= 0x3003) 311 | || 0x3008 === $c 312 | || 0x3009 === $c 313 | || 0x300A === $c 314 | || 0x300B === $c 315 | || 0x300C === $c 316 | || 0x300D === $c 317 | || 0x300E === $c 318 | || 0x300F === $c 319 | || 0x3010 === $c 320 | || 0x3011 === $c 321 | || ($c >= 0x3012 && $c <= 0x3013) 322 | || 0x3014 === $c 323 | || 0x3015 === $c 324 | || 0x3016 === $c 325 | || 0x3017 === $c 326 | || 0x3018 === $c 327 | || 0x3019 === $c 328 | || 0x301A === $c 329 | || 0x301B === $c 330 | || 0x301C === $c 331 | || 0x301D === $c 332 | || ($c >= 0x301E && $c <= 0x301F) 333 | || 0x3020 === $c 334 | || 0x3030 === $c 335 | || 0xFD3E === $c 336 | || 0xFD3F === $c 337 | || ($c >= 0xFE45 && $c <= 0xFE46) 338 | ; 339 | } 340 | 341 | public static function fromCodePoint(int ...$codePoints): string 342 | { 343 | $elements = ''; 344 | $length = \count($codePoints); 345 | $i = 0; 346 | while ($length > $i) { 347 | $code = $codePoints[$i++]; 348 | if ($code > 0x10FFFF) { 349 | throw RangeError($code + ' is not a valid code point'); 350 | } 351 | 352 | $elements .= mb_chr($code, 'UTF-8'); 353 | } 354 | 355 | return $elements; 356 | } 357 | 358 | public static function matchIdentifierAtIndex(AbstractString $s, int $index): string 359 | { 360 | $match = []; 361 | 362 | while (true) { 363 | $c = $s->codePointsAt($index)[0] ?? null; 364 | if (null === $c || self::isWhiteSpace($c) || self::isPatternSyntax($c)) { 365 | break; 366 | } 367 | 368 | $match[] = $c; 369 | $index += $c >= 0x10000 ? 2 : 1; 370 | } 371 | 372 | return self::fromCodePoint(...$match); 373 | } 374 | 375 | public static function isSafeInteger(mixed $value): bool 376 | { 377 | return \is_int($value) && is_finite($value) && abs($value) <= \PHP_INT_MAX; 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/MessageParameters/Extractor/ExtractorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\MessageParameters\Extractor; 13 | 14 | /** 15 | * @author Hugo Alliaume 16 | * 17 | * @internal 18 | */ 19 | interface ExtractorInterface 20 | { 21 | /** 22 | * @return array}|array{ type: 'date' }> 23 | * 24 | * @throws \Exception 25 | */ 26 | public function extract(string $message): array; 27 | } 28 | -------------------------------------------------------------------------------- /src/MessageParameters/Extractor/IntlMessageParametersExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\MessageParameters\Extractor; 13 | 14 | use Symfony\UX\Translator\Intl\IntlMessageParser; 15 | use Symfony\UX\Translator\Intl\Type; 16 | 17 | /** 18 | * @author Hugo Alliaume 19 | * 20 | * @internal 21 | */ 22 | final class IntlMessageParametersExtractor implements ExtractorInterface 23 | { 24 | public function extract(string $message): array 25 | { 26 | $parameters = []; 27 | 28 | $intlMessageParser = new IntlMessageParser($message); 29 | $ast = $intlMessageParser->parse(); 30 | if ($ast['err']) { 31 | throw new \InvalidArgumentException(\sprintf('The message "%s" is not a valid Intl message.', $message)); 32 | } 33 | 34 | $nodes = $ast['val']; 35 | 36 | while ([] !== $nodes) { 37 | $node = array_shift($nodes); 38 | 39 | switch ($node['type']) { 40 | case Type::LITERAL: 41 | break; 42 | 43 | case Type::ARGUMENT: 44 | $parameters[$node['value']] = ['type' => 'string']; 45 | break; 46 | 47 | case Type::NUMBER: 48 | $parameters[$node['value']] = ['type' => 'number']; 49 | break; 50 | 51 | case Type::DATE: 52 | case Type::TIME: 53 | $parameters[$node['value']] = ['type' => 'date']; 54 | break; 55 | 56 | case Type::SELECT: 57 | $parameters[$node['value']] = [ 58 | 'type' => 'string', 59 | 'values' => array_keys($node['options']), 60 | ]; 61 | 62 | foreach ($node['options'] as $option) { 63 | foreach ($option['value'] as $nodeValue) { 64 | $nodes[] = $nodeValue; 65 | } 66 | } 67 | 68 | break; 69 | 70 | case Type::PLURAL: 71 | $parameters[$node['value']] = [ 72 | 'type' => 'number', 73 | ]; 74 | 75 | foreach ($node['options'] as $option) { 76 | foreach ($option['value'] as $nodeValue) { 77 | $nodes[] = $nodeValue; 78 | } 79 | } 80 | 81 | break; 82 | } 83 | } 84 | 85 | return $parameters; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/MessageParameters/Extractor/MessageParametersExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\MessageParameters\Extractor; 13 | 14 | /** 15 | * @author Hugo Alliaume 16 | * 17 | * @internal 18 | */ 19 | final class MessageParametersExtractor implements ExtractorInterface 20 | { 21 | private const RE_PARAMETER = '/(%\w+%)|({{ \w+ }})/'; 22 | 23 | public function extract(string $message): array 24 | { 25 | $parameters = []; 26 | 27 | if (false !== preg_match_all(self::RE_PARAMETER, $message, $matches)) { 28 | foreach ($matches[0] as $match) { 29 | $parameters[$match] = [ 30 | 'type' => '%count%' === $match ? 'number' : 'string', 31 | ]; 32 | } 33 | } 34 | 35 | return $parameters; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator\MessageParameters\Printer; 13 | 14 | /** 15 | * @author Hugo Alliaume 16 | * 17 | * @internal 18 | */ 19 | final class TypeScriptMessageParametersPrinter 20 | { 21 | /** 22 | * @param array}|array{ type: 'date' }> $parameters 23 | */ 24 | public function print(array $parameters): string 25 | { 26 | if ([] === $parameters) { 27 | return 'NoParametersType'; 28 | } 29 | 30 | $type = '{ '; 31 | foreach ($parameters as $parameterName => $parameter) { 32 | switch ($parameter['type']) { 33 | case 'number': 34 | $value = 'number'; 35 | break; 36 | case 'string': 37 | if (\is_array($parameter['values'] ?? null)) { 38 | $value = implode( 39 | '|', 40 | array_map( 41 | fn (string $val) => 'other' === $val ? 'string' : "'".$val."'", 42 | $parameter['values'] 43 | ) 44 | ); 45 | } else { 46 | $value = 'string'; 47 | } 48 | break; 49 | case 'date': 50 | $value = 'Date'; 51 | break; 52 | default: 53 | throw new \InvalidArgumentException(\sprintf('Unknown type "%s" for parameter "%s"', $parameter['type'], $parameterName)); 54 | } 55 | 56 | $type .= \sprintf("'%s': %s, ", $parameterName, $value); 57 | } 58 | 59 | $type = rtrim($type, ', '); 60 | $type .= ' }'; 61 | 62 | return $type; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/TranslationsDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator; 13 | 14 | use Symfony\Component\Filesystem\Filesystem; 15 | use Symfony\Component\Translation\MessageCatalogueInterface; 16 | use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor; 17 | use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; 18 | use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; 19 | 20 | use function Symfony\Component\String\s; 21 | 22 | /** 23 | * @author Hugo Alliaume 24 | * 25 | * @final 26 | * 27 | * @experimental 28 | * 29 | * @phpstan-type Domain string 30 | * @phpstan-type Locale string 31 | * @phpstan-type MessageId string 32 | */ 33 | class TranslationsDumper 34 | { 35 | private array $excludedDomains = []; 36 | private array $includedDomains = []; 37 | 38 | public function __construct( 39 | private string $dumpDir, 40 | private MessageParametersExtractor $messageParametersExtractor, 41 | private IntlMessageParametersExtractor $intlMessageParametersExtractor, 42 | private TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter, 43 | private Filesystem $filesystem, 44 | ) { 45 | } 46 | 47 | public function dump(MessageCatalogueInterface ...$catalogues): void 48 | { 49 | $this->filesystem->mkdir($this->dumpDir); 50 | $this->filesystem->remove($this->dumpDir.'/index.js'); 51 | $this->filesystem->remove($this->dumpDir.'/index.d.ts'); 52 | $this->filesystem->remove($this->dumpDir.'/configuration.js'); 53 | $this->filesystem->remove($this->dumpDir.'/configuration.d.ts'); 54 | 55 | $translationsJs = ''; 56 | $translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n"; 57 | 58 | foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) { 59 | $constantName = $this->generateConstantName($translationId); 60 | 61 | $translationsJs .= \sprintf( 62 | "export const %s = %s;\n", 63 | $constantName, 64 | json_encode([ 65 | 'id' => $translationId, 66 | 'translations' => $translationsByDomainAndLocale, 67 | ], \JSON_THROW_ON_ERROR), 68 | ); 69 | $translationsTs .= \sprintf( 70 | "export declare const %s: %s;\n", 71 | $constantName, 72 | $this->getTranslationsTypeScriptTypeDefinition($translationsByDomainAndLocale) 73 | ); 74 | } 75 | 76 | $this->filesystem->dumpFile($this->dumpDir.'/index.js', $translationsJs); 77 | $this->filesystem->dumpFile($this->dumpDir.'/index.d.ts', $translationsTs); 78 | $this->filesystem->dumpFile($this->dumpDir.'/configuration.js', \sprintf( 79 | "export const localeFallbacks = %s;\n", 80 | json_encode($this->getLocaleFallbacks(...$catalogues), \JSON_THROW_ON_ERROR) 81 | )); 82 | $this->filesystem->dumpFile($this->dumpDir.'/configuration.d.ts', <<<'TS' 83 | import { LocaleType } from '@symfony/ux-translator'; 84 | 85 | export declare const localeFallbacks: Record; 86 | TS 87 | ); 88 | } 89 | 90 | public function addExcludedDomain(string $domain): void 91 | { 92 | if ($this->includedDomains) { 93 | throw new \LogicException('You cannot set both "excluded_domains" and "included_domains" at the same time.'); 94 | } 95 | $this->excludedDomains[] = $domain; 96 | } 97 | 98 | public function addIncludedDomain(string $domain): void 99 | { 100 | if ($this->excludedDomains) { 101 | throw new \LogicException('You cannot set both "excluded_domains" and "included_domains" at the same time.'); 102 | } 103 | $this->includedDomains[] = $domain; 104 | } 105 | 106 | /** 107 | * @return array>> 108 | */ 109 | private function getTranslations(MessageCatalogueInterface ...$catalogues): array 110 | { 111 | $translations = []; 112 | 113 | foreach ($catalogues as $catalogue) { 114 | $locale = $catalogue->getLocale(); 115 | foreach ($catalogue->getDomains() as $domain) { 116 | if (\in_array($domain, $this->excludedDomains, true)) { 117 | continue; 118 | } 119 | if ($this->includedDomains && !\in_array($domain, $this->includedDomains, true)) { 120 | continue; 121 | } 122 | foreach ($catalogue->all($domain) as $id => $message) { 123 | $realDomain = $catalogue->has($id, $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX) 124 | ? $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX 125 | : $domain; 126 | 127 | $translations[$id] ??= []; 128 | $translations[$id][$realDomain] ??= []; 129 | $translations[$id][$realDomain][$locale] = $message; 130 | } 131 | } 132 | } 133 | 134 | return $translations; 135 | } 136 | 137 | /** 138 | * @param array> $translationsByDomainAndLocale 139 | * 140 | * @throws \Exception 141 | */ 142 | private function getTranslationsTypeScriptTypeDefinition(array $translationsByDomainAndLocale): string 143 | { 144 | $parametersTypes = []; 145 | $locales = []; 146 | 147 | foreach ($translationsByDomainAndLocale as $domain => $translationsByLocale) { 148 | foreach ($translationsByLocale as $locale => $translation) { 149 | try { 150 | $parameters = str_ends_with($domain, MessageCatalogueInterface::INTL_DOMAIN_SUFFIX) 151 | ? $this->intlMessageParametersExtractor->extract($translation) 152 | : $this->messageParametersExtractor->extract($translation); 153 | } catch (\Throwable $e) { 154 | throw new \Exception(\sprintf('Error while extracting parameters from message "%s" in domain "%s" and locale "%s".', $translation, $domain, $locale), previous: $e); 155 | } 156 | 157 | $parametersTypes[$domain] = $this->typeScriptMessageParametersPrinter->print($parameters); 158 | $locales[] = $locale; 159 | } 160 | } 161 | 162 | $typeScriptParametersType = []; 163 | foreach ($parametersTypes as $domain => $parametersType) { 164 | $typeScriptParametersType[] = \sprintf("'%s': { parameters: %s }", $domain, $parametersType); 165 | } 166 | 167 | return \sprintf( 168 | 'Message<{ %s }, %s>', 169 | implode(', ', $typeScriptParametersType), 170 | implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))), 171 | ); 172 | } 173 | 174 | private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): array 175 | { 176 | $localesFallbacks = []; 177 | 178 | foreach ($catalogues as $catalogue) { 179 | $localesFallbacks[$catalogue->getLocale()] = $catalogue->getFallbackCatalogue()?->getLocale(); 180 | } 181 | 182 | return $localesFallbacks; 183 | } 184 | 185 | private function generateConstantName(string $translationId): string 186 | { 187 | static $alreadyGenerated = []; 188 | 189 | $translationId = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString(); 190 | $prefix = 0; 191 | do { 192 | $constantName = $translationId.($prefix > 0 ? '_'.$prefix : ''); 193 | ++$prefix; 194 | } while ($alreadyGenerated[$constantName] ?? false); 195 | 196 | $alreadyGenerated[$constantName] = true; 197 | 198 | return $constantName; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/UxTranslatorBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Translator; 13 | 14 | use Symfony\Component\HttpKernel\Bundle\Bundle; 15 | 16 | /** 17 | * @author Hugo Alliaume 18 | * 19 | * @final 20 | * 21 | * @experimental 22 | */ 23 | class UxTranslatorBundle extends Bundle 24 | { 25 | public function getPath(): string 26 | { 27 | return \dirname(__DIR__); 28 | } 29 | } 30 | --------------------------------------------------------------------------------