diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eee659821ef..981caecc92c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -164,6 +164,15 @@ jobs: working-directory: src/Autocomplete run: php vendor/bin/simple-phpunit + - name: Translator Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Translator + dependency-versions: lowest + - name: Translator Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Translator + tests-php-high-deps: runs-on: ubuntu-latest steps: @@ -261,6 +270,14 @@ jobs: - name: Notify Tests working-directory: src/Notify run: php vendor/bin/simple-phpunit + + - name: Translator Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Translator + - name: Translator Tests + working-directory: src/Translator + run: php vendor/bin/simple-phpunit tests-js: runs-on: ubuntu-latest diff --git a/src/Translator/.gitattributes b/src/Translator/.gitattributes new file mode 100644 index 00000000000..2a135e9fd81 --- /dev/null +++ b/src/Translator/.gitattributes @@ -0,0 +1,8 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/assets/src export-ignore +/assets/test export-ignore +/assets/jest.config.js export-ignore +/tests export-ignore diff --git a/src/Translator/.gitignore b/src/Translator/.gitignore new file mode 100644 index 00000000000..30282084317 --- /dev/null +++ b/src/Translator/.gitignore @@ -0,0 +1,4 @@ +vendor +composer.lock +.php_cs.cache +.phpunit.result.cache diff --git a/src/Translator/.symfony.bundle.yaml b/src/Translator/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Translator/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Translator/CHANGELOG.md b/src/Translator/CHANGELOG.md new file mode 100644 index 00000000000..9603bd3c5f1 --- /dev/null +++ b/src/Translator/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +- Component added diff --git a/src/Translator/LICENSE b/src/Translator/LICENSE new file mode 100644 index 00000000000..3ed9f412ce5 --- /dev/null +++ b/src/Translator/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Translator/README.md b/src/Translator/README.md new file mode 100644 index 00000000000..efa99f52f71 --- /dev/null +++ b/src/Translator/README.md @@ -0,0 +1,16 @@ +# Symfony UX Translator + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Translator integrates [Symfony Translation](https://symfony.com/doc/current/translation.html) for JavaScript. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-translator/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Translator/assets/dist/formatters/formatter.d.ts b/src/Translator/assets/dist/formatters/formatter.d.ts new file mode 100644 index 00000000000..c83aeee2a46 --- /dev/null +++ b/src/Translator/assets/dist/formatters/formatter.d.ts @@ -0,0 +1 @@ +export declare function format(id: string, parameters: Record | undefined, locale: string): string; diff --git a/src/Translator/assets/dist/formatters/intl-formatter.d.ts b/src/Translator/assets/dist/formatters/intl-formatter.d.ts new file mode 100644 index 00000000000..26bac0a11d3 --- /dev/null +++ b/src/Translator/assets/dist/formatters/intl-formatter.d.ts @@ -0,0 +1 @@ +export declare function formatIntl(id: string, parameters: Record | undefined, locale: string): string; diff --git a/src/Translator/assets/dist/translator.d.ts b/src/Translator/assets/dist/translator.d.ts new file mode 100644 index 00000000000..b543730db3a --- /dev/null +++ b/src/Translator/assets/dist/translator.d.ts @@ -0,0 +1,26 @@ +export type DomainType = string; +export type LocaleType = string; +export type TranslationsType = Record; +export type NoParametersType = Record; +export type ParametersType = Record | NoParametersType; +export type RemoveIntlIcuSuffix = T extends `${infer U}+intl-icu` ? U : T; +export type DomainsOf = M extends Message ? keyof Translations : never; +export type LocaleOf = M extends Message ? Locale : never; +export type ParametersOf = M extends Message ? Translations[D] extends { + parameters: infer Parameters; +} ? Parameters : never : never; +export interface Message { + id: string; + translations: { + [domain in DomainType]: { + [locale in Locale]: string; + }; + }; +} +export declare function setLocale(locale: LocaleType | null): void; +export declare function getLocale(): LocaleType; +export declare function setLocaleFallbacks(localeFallbacks: Record): void; +export declare function getLocaleFallbacks(): Record; +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; diff --git a/src/Translator/assets/dist/translator_controller.d.ts b/src/Translator/assets/dist/translator_controller.d.ts new file mode 100644 index 00000000000..aff1f5870eb --- /dev/null +++ b/src/Translator/assets/dist/translator_controller.d.ts @@ -0,0 +1 @@ +export * from './translator'; diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js new file mode 100644 index 00000000000..d4f3317a8b6 --- /dev/null +++ b/src/Translator/assets/dist/translator_controller.js @@ -0,0 +1,253 @@ +import { IntlMessageFormat } from 'intl-messageformat'; + +function formatIntl(id, parameters = {}, locale) { + if (id === '') { + return ''; + } + const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); + parameters = Object.assign({}, parameters); + Object.entries(parameters).forEach(([key, value]) => { + if (key.includes('%') || key.includes('{')) { + delete parameters[key]; + parameters[key.replace(/[%{} ]/g, '').trim()] = value; + } + }); + return intlMessage.format(parameters); +} + +function strtr(string, replacePairs) { + const regex = Object.entries(replacePairs).map(([from]) => { + return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); + }); + if (regex.length === 0) { + return string; + } + return string.replace(new RegExp(regex.join('|'), 'g'), (matched) => replacePairs[matched].toString()); +} + +function format(id, parameters = {}, locale) { + if (null === id || '' === id) { + return ''; + } + if (typeof parameters['%count%'] === 'undefined' || Number.isNaN(parameters['%count%'])) { + return strtr(id, parameters); + } + const number = Number(parameters['%count%']); + let parts = []; + if (/^\|+$/.test(id)) { + parts = id.split('|'); + } + else { + const matches = id.match(/(?:\|\||[^|])+/g); + if (matches !== null) { + parts = matches; + } + } + const intervalRegex = /^(?({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?[[\]])\s*(?-Inf|-?\d+(\.\d+)?)\s*,\s*(?\+?Inf|-?\d+(\.\d+)?)\s*(?[[\]]))\s*(?.*?)$/s; + const standardRules = []; + for (let part of parts) { + part = part.trim().replace(/\|\|/g, '|'); + let matches = part.match(intervalRegex); + if (matches !== null) { + if (matches[2]) { + for (const n of matches[3].split(',')) { + if (number === Number(n)) { + return strtr(matches.groups['message'], parameters); + } + } + } + else { + const leftNumber = '-Inf' === matches.groups['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups['left']); + const rightNumber = ['Inf', '+Inf'].includes(matches.groups['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups['right']); + if (('[' === matches.groups['left_delimiter'] ? number >= leftNumber : number > leftNumber) + && (']' === matches.groups['right_delimiter'] ? number <= rightNumber : number < rightNumber)) { + return strtr(matches.groups['message'], parameters); + } + } + } + else { + matches = part.match(/^\w+:\s*(.*?)$/); + if (matches !== null) { + standardRules.push(matches[1]); + } + else { + standardRules.push(part); + } + } + } + const position = getPluralizationRule(number, locale); + if (typeof standardRules[position] === 'undefined') { + if (1 === parts.length && typeof standardRules[0] !== 'undefined') { + return strtr(standardRules[0], parameters); + } + 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").`); + } + return strtr(standardRules[position], parameters); +} +function getPluralizationRule(number, locale) { + number = Math.abs(number); + let _locale = locale; + if (locale === 'pt_BR' || locale === 'en_US_POSIX') { + return 0; + } + _locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf('_')) : _locale; + switch (_locale) { + case 'af': + case 'bn': + case 'bg': + case 'ca': + case 'da': + case 'de': + case 'el': + case 'en': + case 'en_US_POSIX': + case 'eo': + case 'es': + case 'et': + case 'eu': + case 'fa': + case 'fi': + case 'fo': + case 'fur': + case 'fy': + case 'gl': + case 'gu': + case 'ha': + case 'he': + case 'hu': + case 'is': + case 'it': + case 'ku': + case 'lb': + case 'ml': + case 'mn': + case 'mr': + case 'nah': + case 'nb': + case 'ne': + case 'nl': + case 'nn': + case 'no': + case 'oc': + case 'om': + case 'or': + case 'pa': + case 'pap': + case 'ps': + case 'pt': + case 'so': + case 'sq': + case 'sv': + case 'sw': + case 'ta': + case 'te': + case 'tk': + case 'ur': + case 'zu': + return (1 == number) ? 0 : 1; + case 'am': + case 'bh': + case 'fil': + case 'fr': + case 'gun': + case 'hi': + case 'hy': + case 'ln': + case 'mg': + case 'nso': + case 'pt_BR': + case 'ti': + case 'wa': + return (number < 2) ? 0 : 1; + case 'be': + case 'bs': + case 'hr': + case 'ru': + case 'sh': + case 'sr': + case 'uk': + return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2); + case 'cs': + case 'sk': + return (1 == number) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2); + case 'ga': + return (1 == number) ? 0 : ((2 == number) ? 1 : 2); + case 'lt': + return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2); + case 'sl': + return (1 == number % 100) ? 0 : ((2 == number % 100) ? 1 : (((3 == number % 100) || (4 == number % 100)) ? 2 : 3)); + case 'mk': + return (1 == number % 10) ? 0 : 1; + case 'mt': + return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3)); + case 'lv': + return (0 == number) ? 0 : (((1 == number % 10) && (11 != number % 100)) ? 1 : 2); + case 'pl': + return (1 == number) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2); + case 'cy': + return (1 == number) ? 0 : ((2 == number) ? 1 : (((8 == number) || (11 == number)) ? 2 : 3)); + case 'ro': + return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2); + case 'ar': + return (0 == number) ? 0 : ((1 == number) ? 1 : ((2 == number) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5)))); + default: + return 0; + } +} + +let _locale = null; +let _localeFallbacks = {}; +function setLocale(locale) { + _locale = locale; +} +function getLocale() { + return (_locale || + document.documentElement.getAttribute('data-symfony-ux-translator-locale') || + document.documentElement.lang || + 'en'); +} +function setLocaleFallbacks(localeFallbacks) { + _localeFallbacks = localeFallbacks; +} +function getLocaleFallbacks() { + return _localeFallbacks; +} +function trans(message, parameters = {}, domain = 'messages', locale = null) { + if (typeof domain === 'undefined') { + domain = 'messages'; + } + if (typeof locale === 'undefined' || null === locale) { + locale = getLocale(); + } + if (typeof message.translations === 'undefined') { + return message.id; + } + const localesFallbacks = getLocaleFallbacks(); + const translationsIntl = message.translations[`${domain}+intl-icu`]; + if (typeof translationsIntl !== 'undefined') { + while (typeof translationsIntl[locale] === 'undefined') { + locale = localesFallbacks[locale]; + if (!locale) { + break; + } + } + if (locale) { + return formatIntl(translationsIntl[locale], parameters, locale); + } + } + const translations = message.translations[domain]; + if (typeof translations !== 'undefined') { + while (typeof translations[locale] === 'undefined') { + locale = localesFallbacks[locale]; + if (!locale) { + break; + } + } + if (locale) { + return format(translations[locale], parameters, locale); + } + } + return message.id; +} + +export { getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, trans }; diff --git a/src/Translator/assets/dist/utils.d.ts b/src/Translator/assets/dist/utils.d.ts new file mode 100644 index 00000000000..b6621dfd66c --- /dev/null +++ b/src/Translator/assets/dist/utils.d.ts @@ -0,0 +1 @@ +export declare function strtr(string: string, replacePairs: Record): string; diff --git a/src/Translator/assets/jest.config.js b/src/Translator/assets/jest.config.js new file mode 100644 index 00000000000..0373cb6e952 --- /dev/null +++ b/src/Translator/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../jest.config.js'); diff --git a/src/Translator/assets/package.json b/src/Translator/assets/package.json new file mode 100644 index 00000000000..890a20d2054 --- /dev/null +++ b/src/Translator/assets/package.json @@ -0,0 +1,20 @@ +{ + "name": "@symfony/ux-translator", + "description": "Symfony Translator for JavaScript", + "license": "MIT", + "version": "1.0.0", + "main": "dist/translator_controller.js", + "types": "dist/translator_controller.d.ts", + "peerDependencies": { + "intl-messageformat": "^10.2.5" + }, + "peerDependenciesMeta": { + "intl-messageformat": { + "optional": false + } + }, + "devDependencies": { + "intl-messageformat": "^10.2.5", + "ts-jest": "^27.1.5" + } +} diff --git a/src/Translator/assets/src/formatters/formatter.ts b/src/Translator/assets/src/formatters/formatter.ts new file mode 100644 index 00000000000..e50305ab90c --- /dev/null +++ b/src/Translator/assets/src/formatters/formatter.ts @@ -0,0 +1,238 @@ +import {strtr} from '../utils'; + +/** + * This code is adapted from the Symfony Translator Trait (6.2) + * @see https://github.com/symfony/symfony/blob/015d5015e353ee5448bf7c350de0db4a03f8e13a/src/Symfony/Contracts/Translation/TranslatorTrait.php + */ + +/** + * Translates the given message. + * + * When a number is provided as a parameter named "%count%", the message is parsed for plural + * forms and a translation is chosen according to this number using the following rules: + * + * Given a message with different plural translations separated by a + * pipe (|), this method returns the correct portion of the message based + * on the given number, locale and the pluralization rules in the message + * itself. + * + * The message supports two different types of pluralization rules: + * + * interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples + * indexed: There is one apple|There are %count% apples + * + * The indexed solution can also contain labels (e.g. one: There is one apple). + * This is purely for making the translations more clear - it does not + * affect the functionality. + * + * The two methods can also be mixed: + * {0} There are no apples|one: There is one apple|more: There are %count% apples + * + * An interval can represent a finite set of numbers: + * {1,2,3,4} + * + * An interval can represent numbers between two numbers: + * [1, +Inf] + * ]-1,2[ + * + * The left delimiter can be [ (inclusive) or ] (exclusive). + * The right delimiter can be [ (exclusive) or ] (inclusive). + * Beside numbers, you can use -Inf and +Inf for the infinite. + * + * @see https://en.wikipedia.org/wiki/ISO_31-11 + * + * @private + * + * @param id The message id + * @param parameters An array of parameters for the message + * @param locale The locale + */ +export function format(id: string, parameters: Record = {}, locale: string): string { + if (null === id || '' === id) { + return ''; + } + + if (typeof parameters['%count%'] === 'undefined' || Number.isNaN(parameters['%count%'])) { + return strtr(id, parameters); + } + + const number = Number(parameters['%count%']); + + let parts: Array = []; + if (/^\|+$/.test(id)) { + parts = id.split('|'); + } else { + const matches = id.match(/(?:\|\||[^|])+/g); + if (matches !== null) { + parts = matches; + } + } + + const intervalRegex = /^(?({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?[[\]])\s*(?-Inf|-?\d+(\.\d+)?)\s*,\s*(?\+?Inf|-?\d+(\.\d+)?)\s*(?[[\]]))\s*(?.*?)$/s; + + const standardRules: Array = []; + for (let part of parts) { + part = part.trim().replace(/\|\|/g, '|'); + + let matches = part.match(intervalRegex); + if (matches !== null) { + if (matches[2]) { + for (const n of matches[3].split(',')) { + if (number === Number(n)) { + return strtr(matches.groups!['message'], parameters); + } + } + } else { + const leftNumber = '-Inf' === matches.groups!['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups!['left']); + const rightNumber = ['Inf', '+Inf'].includes(matches.groups!['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups!['right']); + + if (('[' === matches.groups!['left_delimiter'] ? number >= leftNumber : number > leftNumber) + && (']' === matches.groups!['right_delimiter'] ? number <= rightNumber : number < rightNumber) + ) { + return strtr(matches.groups!['message'], parameters); + } + } + } else { + matches = part.match(/^\w+:\s*(.*?)$/); + if (matches !== null) { + standardRules.push(matches[1]); + } else { + standardRules.push(part); + } + } + } + + const position = getPluralizationRule(number, locale); + if (typeof standardRules[position] === 'undefined') { + // when there's exactly one rule given, and that rule is a standard + // rule, use this rule + if (1 === parts.length && typeof standardRules[0] !== 'undefined') { + return strtr(standardRules[0], parameters); + } + + 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").`) + } + + return strtr(standardRules[position], parameters); +} + +/** + * Returns the plural position to use for the given locale and number. + * + * The plural rules are derived from code of the Zend Framework (2010-09-25), + * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd). + * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) + */ +function getPluralizationRule(number: number, locale: string): number { + number = Math.abs(number); + let _locale = locale; + + if (locale === 'pt_BR' || locale === 'en_US_POSIX') { + return 0; + } + + _locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf('_')) : _locale; + + switch (_locale) { + case 'af': + case 'bn': + case 'bg': + case 'ca': + case 'da': + case 'de': + case 'el': + case 'en': + case 'en_US_POSIX': + case 'eo': + case 'es': + case 'et': + case 'eu': + case 'fa': + case 'fi': + case 'fo': + case 'fur': + case 'fy': + case 'gl': + case 'gu': + case 'ha': + case 'he': + case 'hu': + case 'is': + case 'it': + case 'ku': + case 'lb': + case 'ml': + case 'mn': + case 'mr': + case 'nah': + case 'nb': + case 'ne': + case 'nl': + case 'nn': + case 'no': + case 'oc': + case 'om': + case 'or': + case 'pa': + case 'pap': + case 'ps': + case 'pt': + case 'so': + case 'sq': + case 'sv': + case 'sw': + case 'ta': + case 'te': + case 'tk': + case 'ur': + case 'zu': + return (1 == number) ? 0 : 1; + case 'am': + case 'bh': + case 'fil': + case 'fr': + case 'gun': + case 'hi': + case 'hy': + case 'ln': + case 'mg': + case 'nso': + case 'pt_BR': + case 'ti': + case 'wa': + return (number < 2) ? 0 : 1; + case 'be': + case 'bs': + case 'hr': + case 'ru': + case 'sh': + case 'sr': + case 'uk': + return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2); + case 'cs': + case 'sk': + return (1 == number) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2); + case 'ga': + return (1 == number) ? 0 : ((2 == number) ? 1 : 2); + case 'lt': + return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2); + case 'sl': + return (1 == number % 100) ? 0 : ((2 == number % 100) ? 1 : (((3 == number % 100) || (4 == number % 100)) ? 2 : 3)); + case 'mk': + return (1 == number % 10) ? 0 : 1; + case 'mt': + return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3)); + case 'lv': + return (0 == number) ? 0 : (((1 == number % 10) && (11 != number % 100)) ? 1 : 2); + case 'pl': + return (1 == number) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2); + case 'cy': + return (1 == number) ? 0 : ((2 == number) ? 1 : (((8 == number) || (11 == number)) ? 2 : 3)); + case 'ro': + return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2); + case 'ar': + return (0 == number) ? 0 : ((1 == number) ? 1 : ((2 == number) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5)))); + default: + return 0 + } +} diff --git a/src/Translator/assets/src/formatters/intl-formatter.ts b/src/Translator/assets/src/formatters/intl-formatter.ts new file mode 100644 index 00000000000..d1e898f9d2e --- /dev/null +++ b/src/Translator/assets/src/formatters/intl-formatter.ts @@ -0,0 +1,27 @@ +import {IntlMessageFormat} from 'intl-messageformat'; + +/** + * @private + * + * @param id The message id + * @param parameters An array of parameters for the message + * @param locale The locale + */ +export function formatIntl(id: string, parameters: Record = {}, locale: string): string { + if (id === '' ) { + return ''; + } + + const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, {ignoreTag: true}); + + parameters = {...parameters}; + + Object.entries(parameters).forEach(([key, value]) => { + if (key.includes('%') || key.includes('{')) { + delete parameters[key]; + parameters[key.replace(/[%{} ]/g, '').trim()] = value; + } + }); + + return intlMessage.format(parameters); +} diff --git a/src/Translator/assets/src/translator.ts b/src/Translator/assets/src/translator.ts new file mode 100644 index 00000000000..8efb63141a1 --- /dev/null +++ b/src/Translator/assets/src/translator.ts @@ -0,0 +1,167 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +export type DomainType = string; +export type LocaleType = string; + +export type TranslationsType = Record; +export type NoParametersType = Record; +export type ParametersType = Record | NoParametersType; + +export type RemoveIntlIcuSuffix = T extends `${infer U}+intl-icu` ? U : T; +export type DomainsOf = M extends Message ? keyof Translations : never; +export type LocaleOf = M extends Message ? Locale : never; +export type ParametersOf = M extends Message + ? Translations[D] extends { parameters: infer Parameters } + ? Parameters + : never + : never; + +export interface Message { + id: string; + translations: { + [domain in DomainType]: { + [locale in Locale]: string; + }; + }; +} + +import { formatIntl } from './formatters/intl-formatter'; +import { format } from './formatters/formatter'; + +let _locale: LocaleType | null = null; +let _localeFallbacks: Record = {}; + +export function setLocale(locale: LocaleType | null) { + _locale = locale; +} + +export function getLocale(): LocaleType { + return ( + _locale || + document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // + document.documentElement.lang || // + 'en' + ); +} + +export function setLocaleFallbacks(localeFallbacks: Record): void { + _localeFallbacks = localeFallbacks; +} + +export function getLocaleFallbacks(): Record { + return _localeFallbacks; +} + +/** + * Translates the given message, in ICU format (see https://formatjs.io/docs/intl-messageformat) or Symfony format (see below). + * + * When a number is provided as a parameter named "%count%", the message is parsed for plural + * forms and a translation is chosen according to this number using the following rules: + * + * Given a message with different plural translations separated by a + * pipe (|), this method returns the correct portion of the message based + * on the given number, locale and the pluralization rules in the message + * itself. + * + * The message supports two different types of pluralization rules: + * + * interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples + * indexed: There is one apple|There are %count% apples + * + * The indexed solution can also contain labels (e.g. one: There is one apple). + * This is purely for making the translations more clear - it does not + * affect the functionality. + * + * The two methods can also be mixed: + * {0} There are no apples|one: There is one apple|more: There are %count% apples + * + * An interval can represent a finite set of numbers: + * {1,2,3,4} + * + * An interval can represent numbers between two numbers: + * [1, +Inf] + * ]-1,2[ + * + * The left delimiter can be [ (inclusive) or ] (exclusive). + * The right delimiter can be [ (exclusive) or ] (inclusive). + * Beside numbers, you can use -Inf and +Inf for the infinite. + * + * @see https://en.wikipedia.org/wiki/ISO_31-11 + * + * @param message The message + * @param parameters An array of parameters for the message + * @param domain The domain for the message or null to use the default + * @param locale The locale or null to use the default + */ +export function trans< + M extends Message, + 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; +export function trans< + M extends Message, + D extends DomainsOf, + P extends ParametersOf +>( + message: M, + parameters: P = {} as P, + domain: RemoveIntlIcuSuffix> | undefined = 'messages' as RemoveIntlIcuSuffix>, + locale: LocaleOf | null = null +): string { + if (typeof domain === 'undefined') { + domain = 'messages' as RemoveIntlIcuSuffix>; + } + + if (typeof locale === 'undefined' || null === locale) { + locale = getLocale() as LocaleOf; + } + + if (typeof message.translations === 'undefined') { + return message.id; + } + + const localesFallbacks = getLocaleFallbacks(); + + const translationsIntl = message.translations[`${domain}+intl-icu`]; + if (typeof translationsIntl !== 'undefined') { + while (typeof translationsIntl[locale] === 'undefined') { + locale = localesFallbacks[locale] as LocaleOf; + if (!locale) { + break; + } + } + + if (locale) { + return formatIntl(translationsIntl[locale], parameters, locale); + } + } + + const translations = message.translations[domain]; + if (typeof translations !== 'undefined') { + while (typeof translations[locale] === 'undefined') { + locale = localesFallbacks[locale] as LocaleOf; + if (!locale) { + break; + } + } + + if (locale) { + return format(translations[locale], parameters, locale); + } + } + + return message.id; +} diff --git a/src/Translator/assets/src/translator_controller.ts b/src/Translator/assets/src/translator_controller.ts new file mode 100644 index 00000000000..c3b3f09080f --- /dev/null +++ b/src/Translator/assets/src/translator_controller.ts @@ -0,0 +1,12 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +export * from './translator'; diff --git a/src/Translator/assets/src/utils.ts b/src/Translator/assets/src/utils.ts new file mode 100644 index 00000000000..0cb448da203 --- /dev/null +++ b/src/Translator/assets/src/utils.ts @@ -0,0 +1,19 @@ +/** + * PHP strtr's equivalent, inspired and adapted from https://stackoverflow.com/a/37949642. + * + * @private + * + * @param string The string to replace in + * @param replacePairs The pairs of characters to replace + */ +export function strtr(string: string, replacePairs: Record): string { + const regex: Array = Object.entries(replacePairs).map(([from]) => { + return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); + }); + + if (regex.length === 0) { + return string; + } + + return string.replace(new RegExp(regex.join('|'), 'g'), (matched) => replacePairs[matched].toString()); +} diff --git a/src/Translator/assets/test/formatters/formatter.test.ts b/src/Translator/assets/test/formatters/formatter.test.ts new file mode 100644 index 00000000000..7b4753cd2e2 --- /dev/null +++ b/src/Translator/assets/test/formatters/formatter.test.ts @@ -0,0 +1,149 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {format} from '../../src/formatters/formatter'; + +describe('Formatter', function () { + test.concurrent.each<[string, string, Record]>([ + ['Symfony is great!', 'Symfony is great!', {}], + ['Symfony is awesome!', 'Symfony is %what%!', {'%what%': 'awesome'}], + ])('#%# format should returns %p', function (expected, message, parameters) { + expect(format(message, parameters, 'en')).toEqual(expected); + }); + + test.concurrent.each<[string, string, number]>([ + ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], + ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], + ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], + ['There are 0 apples', 'There is 1 apple|There are %count% apples', 0], + ['There is 1 apple', 'There is 1 apple|There are %count% apples', 1], + ['There are 10 apples', 'There is 1 apple|There are %count% apples', 10], + // custom validation messages may be coded with a fixed value + ['There are 2 apples', 'There are 2 apples', 2], + ])('#%# format with choice should returns %p', function (expected, message, number) { + expect(format(message, {'%count%': number}, 'en')).toEqual(expected); + }); + + test.concurrent.each<[string, number, string]>([ + ['foo', 3, '{1,2, 3 ,4}'], + ['bar', 10, '{1,2, 3 ,4}'], + ['bar', 3, '[1,2]'], + ['foo', 1, '[1,2]'], + ['foo', 2, '[1,2]'], + ['bar', 1, ']1,2['], + ['bar', 2, ']1,2['], + ['foo', Math.log(0), '[-Inf,2['], + ['foo', -Math.log(0), '[-2,+Inf]'], + ])('#%# format interval should returns %p', function (expected, number, interval) { + expect(format(interval + ' foo|[1,Inf[ bar', {'%count%': number}, 'en')).toEqual(expected); + }); + + test.concurrent.each<[string, number]>([ + ['{0} There are no apples|{1} There is one apple', 2], + ['{1} There is one apple|]1,Inf] There are %count% apples', 0], + ['{1} There is one apple|]2,Inf] There are %count% apples', 2], + ['{0} There are no apples|There is one apple', 2], + ])('#%# test non matching message', function (message, number) { + expect(() => format(message, {'%count%': number}, 'en')).toThrow(`Unable to choose a translation for "${message}" with locale "en" for value "${number}". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %count% apples").`); + }) + + test.concurrent.each([ + ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], + ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], + ['There are no apples', '{0}There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], + + ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], + + ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], + ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf]There are %count% apples', 10], + ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], + + ['There are 0 apples', 'There is one apple|There are %count% apples', 0], + ['There is one apple', 'There is one apple|There are %count% apples', 1], + ['There are 10 apples', 'There is one apple|There are %count% apples', 10], + + ['There are 0 apples', 'one: There is one apple|more: There are %count% apples', 0], + ['There is one apple', 'one: There is one apple|more: There are %count% apples', 1], + ['There are 10 apples', 'one: There is one apple|more: There are %count% apples', 10], + + ['There are no apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 0], + ['There is one apple', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 1], + ['There are 10 apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 10], + + ['', '{0}|{1} There is one apple|]1,Inf] There are %count% apples', 0], + ['', '{0} There are no apples|{1}|]1,Inf] There are %count% apples', 1], + + // Indexed only tests which are Gettext PoFile* compatible strings. + ['There are 0 apples', 'There is one apple|There are %count% apples', 0], + ['There is one apple', 'There is one apple|There are %count% apples', 1], + ['There are 2 apples', 'There is one apple|There are %count% apples', 2], + + // Tests for float numbers + ['There is almost one apple', '{0} There are no apples|]0,1[ There is almost one apple|{1} There is one apple|[1,Inf] There is more than one apple', 0.7], + ['There is one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1], + ['There is more than one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1.7], + ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], + ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0.0], + ['There are no apples', '{0.0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], + + // Test texts with new-lines + // with double-quotes and \n in id & double-quotes and actual newlines in text + ['This is a text with a\n new-line in it. Selector = 0.', `{0}This is a text with a + new-line in it. Selector = 0.|{1}This is a text with a + new-line in it. Selector = 1.|[1,Inf]This is a text with a + new-line in it. Selector > 1.`, 0], + // with double-quotes and \n in id and single-quotes and actual newlines in text + ['This is a text with a\n new-line in it. Selector = 1.', `{0}This is a text with a + new-line in it. Selector = 0.|{1}This is a text with a + new-line in it. Selector = 1.|[1,Inf]This is a text with a + new-line in it. Selector > 1.`, 1], + ['This is a text with a\n new-line in it. Selector > 1.', `{0}This is a text with a + new-line in it. Selector = 0.|{1}This is a text with a + new-line in it. Selector = 1.|[1,Inf]This is a text with a + new-line in it. Selector > 1.`, 5], + // with double-quotes and id split accros lines + [`This is a text with a + new-line in it. Selector = 1.`, `{0}This is a text with a + new-line in it. Selector = 0.|{1}This is a text with a + new-line in it. Selector = 1.|[1,Inf]This is a text with a + new-line in it. Selector > 1.`, 1], + // with single-quotes and id split accros lines + [`This is a text with a + new-line in it. Selector > 1.`, `{0}This is a text with a + new-line in it. Selector = 0.|{1}This is a text with a + new-line in it. Selector = 1.|[1,Inf]This is a text with a + new-line in it. Selector > 1.`, 5], + // with single-quotes and \n in text + ['This is a text with a\nnew-line in it. Selector = 0.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 0], + // with double-quotes and id split accros lines + ['This is a text with a\nnew-line in it. Selector = 1.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 1], + // esacape pipe + ['This is a text with | in it. Selector = 0.', '{0}This is a text with || in it. Selector = 0.|{1}This is a text with || in it. Selector = 1.', 0], + // Empty plural set (2 plural forms) from a .PO file + ['', '|', 1], + // Empty plural set (3 plural forms) from a .PO file + ['', '||', 1], + + // Floating values + ['1.5 liters', '%count% liter|%count% liters', 1.5], + ['1.5 litre', '%count% litre|%count% litres', 1.5, 'fr'], + + // Negative values + ['-1 degree', '%count% degree|%count% degrees', -1], + ['-1 degré', '%count% degré|%count% degrés', -1], + ['-1.5 degrees', '%count% degree|%count% degrees', -1.5], + ['-1.5 degré', '%count% degré|%count% degrés', -1.5, 'fr'], + ['-2 degrees', '%count% degree|%count% degrees', -2], + ['-2 degrés', '%count% degré|%count% degrés', -2], + ])('#%# test choose', (expected, id, number, locale = 'en') => { + expect(format(id, {'%count%': number}, locale)).toBe(expected); + }) +}); \ No newline at end of file diff --git a/src/Translator/assets/test/formatters/intl-formatter.test.ts b/src/Translator/assets/test/formatters/intl-formatter.test.ts new file mode 100644 index 00000000000..c01d93922d4 --- /dev/null +++ b/src/Translator/assets/test/formatters/intl-formatter.test.ts @@ -0,0 +1,64 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {formatIntl} from '../../src/formatters/intl-formatter'; + +describe('Intl Formatter', function () { + test('format with named arguments', function() { + const chooseMessage = ` +{gender_of_host, select, + female {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to her party.} + =2 {{host} invites {guest} and one other person to her party.} + other {{host} invites {guest} as one of the # people invited to her party.}}} + male {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to his party.} + =2 {{host} invites {guest} and one other person to his party.} + other {{host} invites {guest} as one of the # people invited to his party.}}} + other {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to their party.} + =2 {{host} invites {guest} and one other person to their party.} + other {{host} invites {guest} as one of the # people invited to their party.}}}}`.trim(); + + const message = formatIntl(chooseMessage, { + gender_of_host: 'male', + num_guests: 10, + host: 'Fabien', + guest: 'Guilherme', + }, 'en'); + + expect(message).toEqual('Fabien invites Guilherme as one of the 9 people invited to his party.'); + }) + + test('percents and brackets are trimmed', function() { + expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en')).toEqual('Hello Fab'); + expect(formatIntl('Hello {name}', { '%name%': 'Fab'}, 'en')).toEqual('Hello Fab'); + expect(formatIntl('Hello {name}', { '{{ name }}': 'Fab'}, 'en')).toEqual('Hello Fab'); + + // Parameters object should not be modified + const parameters = { '%name%': 'Fab' }; + expect(formatIntl('Hello {name}', parameters, 'en')).toEqual('Hello Fab'); + expect(parameters).toEqual({ '%name%': 'Fab' }); + }); + + test('format with HTML inside', function() { + expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en')).toEqual('Hello Fab'); + expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en')).toEqual('Hello Fab'); + }) + + test('format with locale containg underscore', function() { + expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en_US')).toEqual('Hello Fab'); + expect(formatIntl('Bonjour {name}', { name: 'Fab'}, 'fr_FR')).toEqual('Bonjour Fab'); + }); +}); \ No newline at end of file diff --git a/src/Translator/assets/test/translator.test.ts b/src/Translator/assets/test/translator.test.ts new file mode 100644 index 00000000000..6f4e05ed34b --- /dev/null +++ b/src/Translator/assets/test/translator.test.ts @@ -0,0 +1,374 @@ +import {getLocale, Message, NoParametersType, setLocale, setLocaleFallbacks, trans} from '../src/translator'; + +describe('Translator', function () { + beforeEach(function() { + setLocale(null); + setLocaleFallbacks({}) + document.documentElement.lang = ''; + document.documentElement.removeAttribute('data-symfony-ux-translator-locale'); + }) + + describe('getLocale', function () { + test('default locale', function () { + // 'en' is the default locale + expect(getLocale()).toEqual('en'); + + // or the locale from , if exists + document.documentElement.lang = 'fr'; + expect(getLocale()).toEqual('fr'); + + // or the locale from , if exists + document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it') + expect(getLocale()).toEqual('it'); + + setLocale('de'); + expect(getLocale()).toEqual('de'); + }); + }); + + describe('setLocale', function () { + test('custom locale', function () { + setLocale('fr'); + + expect(getLocale()).toEqual('fr'); + }); + }); + + describe('trans', function () { + test('basic message', function () { + const MESSAGE_BASIC: Message<{ messages: { parameters: NoParametersType } }, 'en'> = { + id: 'message.basic', + translations: { + messages: { + en: 'A basic message', + } + } + }; + + expect(trans(MESSAGE_BASIC)).toEqual('A basic message') + expect(trans(MESSAGE_BASIC, {})).toEqual('A basic message') + expect(trans(MESSAGE_BASIC, {}, 'messages')).toEqual('A basic message') + expect(trans(MESSAGE_BASIC, {}, 'messages', 'en')).toEqual('A basic message') + + // @ts-expect-error "%count%" is not a valid parameter + expect(trans(MESSAGE_BASIC, {'%count%': 1})).toEqual('A basic message') + + // @ts-expect-error "foo" is not a valid domain + expect(trans(MESSAGE_BASIC, {}, 'foo')).toEqual('message.basic'); + + // @ts-expect-error "fr" is not a valid locale + expect(trans(MESSAGE_BASIC, {}, 'messages', 'fr')).toEqual('message.basic'); + }); + + test('basic message with parameters', function () { + const MESSAGE_BASIC_WITH_PARAMETERS: Message<{ messages: { parameters: { '%parameter1%': string, '%parameter2%': string } } }, 'en'> = { + id: 'message.basic.with.parameters', + translations: { + messages: { + en: 'A basic message %parameter1% %parameter2%', + } + } + }; + + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, { + '%parameter1%': 'foo', + '%parameter2%': 'bar' + })).toEqual('A basic message foo bar'); + + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, { + '%parameter1%': 'foo', + '%parameter2%': 'bar' + }, 'messages')).toEqual('A basic message foo bar'); + + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, { + '%parameter1%': 'foo', + '%parameter2%': 'bar' + }, 'messages', 'en')).toEqual('A basic message foo bar'); + + // @ts-expect-error Parameters "%parameter1%" and "%parameter2%" are missing + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {})).toEqual('A basic message %parameter1% %parameter2%'); + + // @ts-expect-error Parameter "%parameter2%" is missing + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {'%parameter1%': 'foo'})).toEqual('A basic message foo %parameter2%'); + + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, { + '%parameter1%': 'foo', + '%parameter2%': 'bar' + // @ts-expect-error "foobar" is not a valid domain + }, 'foobar')).toEqual('message.basic.with.parameters'); + + expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, { + '%parameter1%': 'foo', + '%parameter2%': 'bar' + // @ts-expect-error "fr" is not a valid locale + }, 'messages', 'fr')).toEqual('message.basic.with.parameters'); + }); + + test('intl message', function () { + const MESSAGE_INTL: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'> = { + id: 'message.intl', + translations: { + 'messages+intl-icu': { + en: 'An intl message', + } + }, + }; + + expect(trans(MESSAGE_INTL)).toEqual('An intl message'); + expect(trans(MESSAGE_INTL, {})).toEqual('An intl message'); + expect(trans(MESSAGE_INTL, {}, 'messages')).toEqual('An intl message'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'en')).toEqual('An intl message'); + + // @ts-expect-error "%count%" is not a valid parameter + expect(trans(MESSAGE_INTL, {'%count%': 1})).toEqual('An intl message'); + + // @ts-expect-error "foo" is not a valid domain + expect(trans(MESSAGE_INTL, {}, 'foo')).toEqual('message.intl'); + + // @ts-expect-error "fr" is not a valid locale + expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('message.intl'); + }); + + test('intl message with parameters', function () { + const INTL_MESSAGE_WITH_PARAMETERS: Message<{ + 'messages+intl-icu': { + parameters: { + gender_of_host: 'male' | 'female' | string, + num_guests: number, + host: string, + guest: string, + } + } + }, 'en'> = { + id: 'message.intl.with.parameters', + translations: { + 'messages+intl-icu': { + en: ` +{gender_of_host, select, + female {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to her party.} + =2 {{host} invites {guest} and one other person to her party.} + other {{host} invites {guest} as one of the # people invited to her party.}}} + male {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to his party.} + =2 {{host} invites {guest} and one other person to his party.} + other {{host} invites {guest} as one of the # people invited to his party.}}} + other {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to their party.} + =2 {{host} invites {guest} and one other person to their party.} + other {{host} invites {guest} as one of the # people invited to their party.}}}}`.trim(), + } + }, + }; + + expect(trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + num_guests: 123, + host: 'John', + guest: 'Mary', + })).toEqual('John invites Mary as one of the 122 people invited to his party.'); + + + expect(trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'female', + num_guests: 44, + host: 'Mary', + guest: 'John', + }, 'messages')).toEqual('Mary invites John as one of the 43 people invited to her party.'); + + expect(trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'female', + num_guests: 1, + host: 'Lola', + guest: 'Hugo', + }, 'messages', 'en')).toEqual('Lola invites Hugo to her party.'); + + expect(function () { + // @ts-expect-error Parameters "gender_of_host", "num_guests", "host", and "guest" are missing + trans(INTL_MESSAGE_WITH_PARAMETERS, {}); + }).toThrow(/^The intl string context variable "gender_of_host" was not provided/); + + expect(function () { + // @ts-expect-error Parameters "num_guests", "host", and "guest" are missing + trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + }); + }).toThrow(/^The intl string context variable "num_guests" was not provided/); + + expect(function () { + // @ts-expect-error Parameters "host", and "guest" are missing + trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + num_guests: 123, + }) + }).toThrow(/^The intl string context variable "host" was not provided/); + + expect(function () { + // @ts-expect-error Parameter "guest" is missing + trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + num_guests: 123, + host: 'John', + }) + }).toThrow(/^The intl string context variable "guest" was not provided/); + + expect( + trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + num_guests: 123, + host: 'John', + guest: 'Mary', + }, + // @ts-expect-error Domain "foobar" is invalid + 'foobar' + )).toEqual('message.intl.with.parameters'); + + expect( + trans(INTL_MESSAGE_WITH_PARAMETERS, { + gender_of_host: 'male', + num_guests: 123, + host: 'John', + guest: 'Mary', + }, + 'messages', + // @ts-expect-error Locale "fr" is invalid + 'fr' + )).toEqual('message.intl.with.parameters'); + }); + + test('same message id for multiple domains', function () { + const MESSAGE_MULTI_DOMAINS: Message<{ foobar: { parameters: NoParametersType }, messages: { parameters: NoParametersType } }, 'en'> = { + id: 'message.multi_domains', + translations: { + foobar: { + en: 'A message from foobar catalogue', + }, + messages: { + en: 'A message from messages catalogue', + } + } + }; + + expect(trans(MESSAGE_MULTI_DOMAINS)).toEqual('A message from messages catalogue'); + expect(trans(MESSAGE_MULTI_DOMAINS, {})).toEqual('A message from messages catalogue'); + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages')).toEqual('A message from messages catalogue'); + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar')).toEqual('A message from foobar catalogue'); + + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages', 'en')).toEqual('A message from messages catalogue'); + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar', 'en')).toEqual('A message from foobar catalogue'); + + // @ts-expect-error Domain "acme" is invalid + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'acme', 'fr')).toEqual('message.multi_domains'); + + // @ts-expect-error Locale "fr" is invalid + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages', 'fr')).toEqual('message.multi_domains'); + + // @ts-expect-error Locale "fr" is invalid + expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar', 'fr')).toEqual('message.multi_domains'); + }); + + test('same message id for multiple domains, and different parameters', function () { + const MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS: Message<{ foobar: { parameters: { '%parameter2%': string } }, messages: { parameters: { '%parameter1%': string } } }, 'en'> = { + id: 'message.multi_domains.different_parameters', + translations: { + foobar: { + en: 'A message from foobar catalogue with a parameter %parameter2%', + }, + messages: { + en: 'A message from messages catalogue with a parameter %parameter1%', + } + } + }; + + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'})).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages')).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages', 'en')).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter2%': 'foo'}, 'foobar')).toEqual('A message from foobar catalogue with a parameter foo'); + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter2%': 'foo'}, 'foobar', 'en')).toEqual('A message from foobar catalogue with a parameter foo'); + + // @ts-expect-error Parameter "%parameter1%" is missing + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {})).toEqual('A message from messages catalogue with a parameter %parameter1%'); + + // @ts-expect-error Domain "baz" is invalid + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'baz')).toEqual('message.multi_domains.different_parameters'); + + // @ts-expect-error Locale "fr" is invalid + expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages', 'fr')).toEqual('message.multi_domains.different_parameters'); + }); + + test('message from intl domain should be prioritized over its non-intl equivalent', function () { + const MESSAGE: Message<{ 'messages+intl-icu': { parameters: NoParametersType }, messages: { parameters: NoParametersType } }, 'en'> = { + id: 'message', + translations: { + 'messages+intl-icu': { + en: 'A intl message', + }, + messages: { + en: 'A basic message', + } + } + } + + expect(trans(MESSAGE)).toEqual('A intl message'); + expect(trans(MESSAGE, {})).toEqual('A intl message'); + expect(trans(MESSAGE, {}, 'messages')).toEqual('A intl message'); + expect(trans(MESSAGE, {}, 'messages', 'en')).toEqual('A intl message'); + }); + + test('fallback behavior', function() { + setLocaleFallbacks({'fr_FR':'fr','fr':'en','en_US':'en','en_GB':'en','de_DE':'de','de':'en'}); + + const MESSAGE: Message<{ messages: { parameters: NoParametersType } }, 'en'|'en_US'|'fr'> = { + id: 'message', + translations: { + messages: { + en: 'A message in english', + en_US: 'A message in english (US)', + fr: 'Un message en français', + } + } + } + + const MESSAGE_INTL: Message<{ messages: { parameters: NoParametersType } }, 'en'|'en_US'|'fr'> = { + id: 'message_intl', + translations: { + messages: { + en: 'A intl message in english', + en_US: 'A intl message in english (US)', + fr: 'Un message intl en français', + } + } + } + + const MESSAGE_FRENCH_ONLY: Message<{ messages: { parameters: NoParametersType } }, 'fr'> = { + id: 'message_french_only', + translations: { + messages: { + fr: 'Un message en français uniquement', + } + } + } + + expect(trans(MESSAGE, {}, 'messages', 'en')).toEqual('A message in english'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'en')).toEqual('A intl message in english'); + expect(trans(MESSAGE, {}, 'messages', 'en_US')).toEqual('A message in english (US)'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'en_US')).toEqual('A intl message in english (US)'); + expect(trans(MESSAGE, {}, 'messages', 'en_GB' as 'en')).toEqual('A message in english'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'en_GB' as 'en')).toEqual('A intl message in english'); + + expect(trans(MESSAGE, {}, 'messages', 'fr')).toEqual('Un message en français'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('Un message intl en français'); + expect(trans(MESSAGE, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message en français'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message intl en français'); + + expect(trans(MESSAGE, {}, 'messages', 'de_DE' as 'en')).toEqual('A message in english'); + expect(trans(MESSAGE_INTL, {}, 'messages', 'de_DE' as 'en')).toEqual('A intl message in english'); + + expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'fr')).toEqual('Un message en français uniquement'); + expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'en' as 'fr')).toEqual('message_french_only'); + }) + }); +}); diff --git a/src/Translator/assets/test/utils.test.ts b/src/Translator/assets/test/utils.test.ts new file mode 100644 index 00000000000..35049a6aba8 --- /dev/null +++ b/src/Translator/assets/test/utils.test.ts @@ -0,0 +1,35 @@ +import {strtr} from '../src/utils'; + +describe('Utils', function () { + test.concurrent.each<[string, string, Record]>([ + // https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_basic.phpt + ['TEST STrTr', 'test strtr', {'t': 'T', 'e': 'E', 'st': 'ST'}], + ['TEST STrTr', 'test strtr', {'t': 'T', 'e': 'E', 'st': 'ST'}], + + // https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_variation1.phpt + ['a23', '123', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}], + ['1bc', 'abc', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}], + ['a1b2c3', '1a2b3c', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}], + [` +a23 +1bc +a1b2c3`, ` +123 +abc +1a2b3c`, {1: 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}], + + // https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_variation2.phpt + ['$', '%', {'$': '%', '%': '$', '#*&@()': '()@&*#'}], + ['#%*', '#$*', {'$': '%', '%': '$', '#*&@()': '()@&*#'}], + ['text & @()', 'text & @()', {'$': '%', '%': '$', '#*&@()': '()@&*#'}], + [` +$ +#%*& +text & @()`, ` +% +#$*& +text & @()`, {'$': '%', '%': '$', '#*&@()': '()@&*#'}], + ])('strtr', function (expected, string, replacePairs) { + expect(strtr(string, replacePairs)).toEqual(expected); + }); +}); \ No newline at end of file diff --git a/src/Translator/composer.json b/src/Translator/composer.json new file mode 100644 index 00000000000..977cb8c987a --- /dev/null +++ b/src/Translator/composer.json @@ -0,0 +1,49 @@ +{ + "name": "symfony/ux-translator", + "type": "symfony-bundle", + "description": "Symfony Translator for JavaScript", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Translator\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Translator\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.0", + "symfony/console": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/string": "^5.4|^6.0", + "symfony/translation": "^5.4|^6.0" + }, + "require-dev": { + "symfony/framework-bundle": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.2|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Translator/config/services.php b/src/Translator/config/services.php new file mode 100644 index 00000000000..e5eb6bab50b --- /dev/null +++ b/src/Translator/config/services.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer; +use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor; +use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; +use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; +use Symfony\UX\Translator\TranslationsDumper; + +/* + * @author Hugo Alliaume + */ +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('ux.translator.cache_warmer.translations_cache_warmer', TranslationsCacheWarmer::class) + ->args([ + service('translator'), + service('ux.translator.translations_dumper'), + ]) + ->tag('kernel.cache_warmer') + + ->set('ux.translator.translations_dumper', TranslationsDumper::class) + ->args([ + null, // Dump directory + service('ux.translator.message_parameters.extractor.message_parameters_extractor'), + service('ux.translator.message_parameters.extractor.intl_message_parameters_extractor'), + service('ux.translator.message_parameters.printer.typescript_message_parameters_printer'), + service('filesystem'), + ]) + + ->set('ux.translator.message_parameters.extractor.message_parameters_extractor', MessageParametersExtractor::class) + + ->set('ux.translator.message_parameters.extractor.intl_message_parameters_extractor', IntlMessageParametersExtractor::class) + + ->set('ux.translator.message_parameters.printer.typescript_message_parameters_printer', TypeScriptMessageParametersPrinter::class) + ; +}; diff --git a/src/Translator/doc/index.rst b/src/Translator/doc/index.rst new file mode 100644 index 00000000000..39e7c0c92a5 --- /dev/null +++ b/src/Translator/doc/index.rst @@ -0,0 +1,118 @@ +Symfony UX Translator +===================== + +Symfony UX Translator is a Symfony bundle providing the same mechanism as `Symfony Translator`_ +in JavaScript with a TypeScript integration, in Symfony applications. It is part of `the Symfony UX initiative`_. + +The `ICU Message Format`_ is also supported. + +Installation +------------ + +Before you start, make sure you have `Symfony UX configured in your app`_. + +Install this bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-translator + + # Don't forget to install the JavaScript dependencies as well and compile + $ npm install --force + $ npm run watch + + # or use yarn + $ yarn install --force + $ yarn watch + +After installing the bundle, the following file should be created, thanks to the Symfony Flex recipe: + +.. code-block:: javascript + + // assets/translator.js + + /* + * This file is part of the Symfony UX Translator package. + * + * If folder "../var/translations" does not exist, or some translations are missing, + * you must warmup your Symfony cache to refresh JavaScript translations. + * + * If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking. + */ + + import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator'; + import { localeFallbacks } from '../var/translations/configuration'; + + setLocaleFallbacks(localeFallbacks); + + export { trans } + export * from '../var/translations'; + +Usage +----- + +When warming up the Symfony cache, all of your translations will be dumped as JavaScript into the ``var/translations/`` directory. +For a better developer experience, TypeScript types definitions are also generated aside those JavaScript files. + +Then, you will be able to import those JavaScript translations in your assets. +Don't worry about your final bundle size, only the translations you use will be included in your final bundle, thanks to the [tree shaking](https://webpack.js.org/guides/tree-shaking/). + +Configuring the default locale +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, the default locale is ``en`` (English) that you can configure through many ways (in order of priority): +1. With ``setLocale('your-locale')`` from ``@symfony/ux-translator`` package +2. Or with ```` attribute +3. Or with ```` attribute + +Importing and using translations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you use the Symfony Flex recipe, you can import the ``trans()`` function and your translations in your assets from the file `assets/translator.js`. + +Translations are available as named exports, by using the translation's id transformed in uppercase snake-case (e.g.: `my.translation` becomes `MY_TRANSLATION`), +so you can import them like this: + +.. code-block:: javascript + + // assets/my_file.js + + import { + trans, + TRANSLATION_SIMPLE, + TRANSLATION_WITH_PARAMETERS, + TRANSLATION_MULTI_DOMAINS, + TRANSLATION_MULTI_LOCALES, + } from './translator'; + + // No parameters, uses the default domain ("messages") and the default locale + trans(TRANSLATION_SIMPLE); + + // Two parameters "count" and "foo", uses the default domain ("messages") and the default locale + trans(TRANSLATION_WITH_PARAMETERS, { count: 123, foo: 'bar' }); + + // No parameters, uses the default domain ("messages") and the default locale + trans(TRANSLATION_MULTI_DOMAINS); + // Same as above, but uses the "domain2" domain + trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain2'); + // Same as above, but uses the "domain3" domain + trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain3'); + + // No parameters, uses the default domain ("messages") and the default locale + trans(TRANSLATION_MULTI_LOCALES); + // Same as above, but uses the "fr" locale + trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'fr'); + // Same as above, but uses the "it" locale + trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'it'); + +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +.. _`Symfony Translator`: https://symfony.com/doc/current/translation.html +.. _`the Symfony UX initiative`: https://symfony.com/ux +.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html +.. _`ICU Message Format`: https://symfony.com/doc/current/translation/message_format.html \ No newline at end of file diff --git a/src/Translator/phpunit.xml.dist b/src/Translator/phpunit.xml.dist new file mode 100644 index 00000000000..66db4da3c27 --- /dev/null +++ b/src/Translator/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + tests + + + + + + ./src + + + diff --git a/src/Translator/src/CacheWarmer/TranslationsCacheWarmer.php b/src/Translator/src/CacheWarmer/TranslationsCacheWarmer.php new file mode 100644 index 00000000000..c0ae7461e8b --- /dev/null +++ b/src/Translator/src/CacheWarmer/TranslationsCacheWarmer.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\CacheWarmer; + +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\UX\Translator\TranslationsDumper; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +class TranslationsCacheWarmer implements CacheWarmerInterface +{ + public function __construct( + private TranslatorBagInterface $translatorBag, + private TranslationsDumper $translationsDumper, + ) { + } + + public function isOptional(): bool + { + return true; + } + + public function warmUp(string $cacheDir): array + { + $this->translationsDumper->dump( + ...$this->translatorBag->getCatalogues() + ); + + // No need to preload anything + return []; + } +} diff --git a/src/Translator/src/DependencyInjection/Configuration.php b/src/Translator/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000000..1821e2641ac --- /dev/null +++ b/src/Translator/src/DependencyInjection/Configuration.php @@ -0,0 +1,27 @@ + + * + * @experimental + */ +class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('ux_translator'); + $rootNode = $treeBuilder->getRootNode(); + $rootNode + ->children() + ->scalarNode('dump_directory')->defaultValue('%kernel.project_dir%/var/translations')->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Translator/src/DependencyInjection/TranslatorExtension.php b/src/Translator/src/DependencyInjection/TranslatorExtension.php new file mode 100644 index 00000000000..ccaffc14ecd --- /dev/null +++ b/src/Translator/src/DependencyInjection/TranslatorExtension.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @author Hugo Alliaume + * + * @internal + * + * @experimental + */ +class TranslatorExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); + $loader->load('services.php'); + + $container->getDefinition('ux.translator.translations_dumper')->setArgument(0, $config['dump_directory']); + } +} diff --git a/src/Translator/src/Intl/ErrorKind.php b/src/Translator/src/Intl/ErrorKind.php new file mode 100644 index 00000000000..cf7248d7888 --- /dev/null +++ b/src/Translator/src/Intl/ErrorKind.php @@ -0,0 +1,92 @@ +foo) */ + public const INVALID_TAG = 'INVALID_TAG'; + + /** The tag name is invalid. (e.g. `<123>foo`) */ + public const INVALID_TAG_NAME = 'INVALID_TAG_NAME'; + + /** The closing tag does not match the opening tag. (e.g. `foo`) */ + public const UNMATCHED_CLOSING_TAG = 'UNMATCHED_CLOSING_TAG'; + + /** The opening tag has unmatched closing tag. (e.g. `foo`) */ + public const UNCLOSED_TAG = 'UNCLOSED_TAG'; + + private function __construct() + { + } +} diff --git a/src/Translator/src/Intl/IntlMessageParser.php b/src/Translator/src/Intl/IntlMessageParser.php new file mode 100644 index 00000000000..246b59d0e96 --- /dev/null +++ b/src/Translator/src/Intl/IntlMessageParser.php @@ -0,0 +1,1018 @@ +message = $message; + $this->position = new Position(0, 1, 1); + $this->ignoreTag = true; + $this->requiresOtherClause = true; + } + + /** + * @throws \Exception + */ + public function parse(): array + { + return $this->parseMessage(0, '', false); + } + + /** + * @throws \Exception + */ + private function parseMessage(int $nestingLevel, mixed $parentArgType, bool $expectingCloseTag): array + { + $elements = []; + + while (!$this->isEOF()) { + $char = $this->char(); + if (123 === $char /* `{` */) { + $result = $this->parseArgument($nestingLevel, $expectingCloseTag); + if ($result['err']) { + return $result; + } + $elements[] = $result['val']; + } elseif (125 === $char /* `}` */ && $nestingLevel > 0) { + break; + } elseif ( + 35 === $char /* `#` */ && + ('plural' === $parentArgType || 'selectordinal' === $parentArgType) + ) { + $position = clone $this->position; + $this->bump(); + $elements[] = [ + 'type' => 'pound', + 'location' => new Location($position, clone $this->position), + ]; + } elseif ( + 60 === $char /* `<` */ && + !$this->ignoreTag && + 47 === $this->peek() // char code for '/' + ) { + if ($expectingCloseTag) { + break; + } else { + return $this->error( + ErrorKind::UNMATCHED_CLOSING_TAG, + new Location(clone $this->position, clone $this->position) + ); + } + } elseif ( + 60 === $char /* `<` */ && + !$this->ignoreTag && + Utils::isAlpha($this->peek() || 0) + ) { + $result = $this->parseTag($nestingLevel, $parentArgType); + if ($result['err']) { + return $result; + } + $elements[] = $result['val']; + } else { + $result = $this->parseLiteral($nestingLevel, $parentArgType); + if ($result['err']) { + return $result; + } + $elements[] = $result['val']; + } + } + + return [ + 'val' => $elements, + 'err' => null, + ]; + } + + /** + * This method assumes that the caller has peeked ahead for the first tag character. + */ + private function parseTagName(): string + { + $startOffset = $this->offset(); + + $this->bump(); // the first tag name character + while (!$this->isEOF() && Utils::isPotentialElementNameChar($this->char())) { + $this->bump(); + } + + return s($this->message)->slice($startOffset, $this->offset() - $startOffset)->toString(); + } + + /** + * @return array{ val: array{ type: Type::LITERAL, value: string, location: Location }, err: null } + */ + private function parseLiteral(int $nestingLevel, string $parentArgType): array + { + $start = clone $this->position; + + $value = ''; + while (true) { + $parseQuoteResult = $this->tryParseQuote($parentArgType); + if ($parseQuoteResult) { + $value .= $parseQuoteResult; + continue; + } + + $parseUnquotedResult = $this->tryParseUnquoted($nestingLevel, $parentArgType); + if ($parseUnquotedResult) { + $value .= $parseUnquotedResult; + continue; + } + + $parseLeftAngleResult = $this->tryParseLeftAngleBracket(); + if ($parseLeftAngleResult) { + $value .= $parseLeftAngleResult; + continue; + } + + break; + } + + $location = new Location($start, clone $this->position); + + return [ + 'val' => [ + 'type' => Type::LITERAL, + 'value' => $value, + 'location' => $location, + ], + 'err' => null, + ]; + } + + private function tryParseLeftAngleBracket(): string|null + { + if ( + !$this->isEOF() && + 60 === $this->char() /* `<` */ && + ($this->ignoreTag || + // If at the opening tag or closing tag position, bail. + !Utils::isAlphaOrSlash($this->peek() || 0)) + ) { + $this->bump(); // `<` + + return '<'; + } + + return null; + } + + /** + * Starting with ICU 4.8, an ASCII apostrophe only starts quoted text if it immediately precedes + * a character that requires quoting (that is, "only where needed"), and works the same in + * nested messages as on the top level of the pattern. The new behavior is otherwise compatible. + */ + private function tryParseQuote(string $parentArgType): string|null + { + if ($this->isEOF() || 39 !== $this->char() /* `'` */) { + return null; + } + + // Parse escaped char following the apostrophe, or early return if there is no escaped char. + // Check if is valid escaped character + switch ($this->peek()) { + case 39 /* `'` */ : + // double quote, should return as a single quote. + $this->bump(); + + $this->bump(); + + return "'"; + // '{', '<', '>', '}' + case 123: + case 60: + case 62: + case 125: + break; + case 35: // '#' + if ('plural' === $parentArgType || 'selectordinal' === $parentArgType) { + break; + } + + return null; + default: + return null; + } + + $this->bump(); // apostrophe + $codePoints = [$this->char()]; // escaped char + $this->bump(); + + // read chars until the optional closing apostrophe is found + while (!$this->isEOF()) { + $ch = $this->char(); + if (39 === $ch /* `'` */) { + if (39 === $this->peek() /* `'` */) { + $codePoints[] = 39; + // Bump one more time because we need to skip 2 characters. + $this->bump(); + } else { + // Optional closing apostrophe. + $this->bump(); + break; + } + } else { + $codePoints[] = $ch; + } + $this->bump(); + } + + return Utils::fromCodePoint(...$codePoints); + } + + private function tryParseUnquoted( + int $nestingLevel, + string $parentArgType + ): string|null { + if ($this->isEOF()) { + return null; + } + $ch = $this->char(); + + if ( + 60 === $ch /* `<` */ || + 123 === $ch /* `{` */ || + (35 === $ch /* `#` */ && + ('plural' === $parentArgType || 'selectordinal' === $parentArgType)) || + (125 === $ch /* `}` */ && $nestingLevel > 0) + ) { + return null; + } else { + $this->bump(); + + return Utils::fromCodePoint($ch); + } + } + + /** + * @return Result + */ + private function parseArgument( + int $nestingLevel, + bool $expectingCloseTag + ): array { + $openingBracePosition = clone $this->position; + $this->bump(); // `{` + + $this->bumpSpace(); + + if ($this->isEOF()) { + return $this->error( + ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, + new Location($openingBracePosition, clone $this->position) + ); + } + + if (125 === $this->char() /* `}` */) { + $this->bump(); + + return $this->error( + ErrorKind::EMPTY_ARGUMENT, + new Location($openingBracePosition, clone $this->position) + ); + } + + // argument name + $value = $this->parseIdentifierIfPossible()['value']; + if (!$value) { + return $this->error( + ErrorKind::MALFORMED_ARGUMENT, + new Location($openingBracePosition, clone $this->position) + ); + } + + $this->bumpSpace(); + + if ($this->isEOF()) { + return $this->error( + ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, + new Location($openingBracePosition, clone $this->position) + ); + } + + switch ($this->char()) { + // Simple argument: `{name}` + case 125 /* `}` */ : + $this->bump(); // `}` + + return [ + 'val' => [ + 'type' => Type::ARGUMENT, + 'value' => $value, + 'location' => new Location($openingBracePosition, clone $this->position), + ], + 'err' => null, + ]; + + // Argument with options: `{name, format, ...}` + case 44 /* `,` */ : + $this->bump(); // `,` + $this->bumpSpace(); + + if ($this->isEOF()) { + return $this->error( + ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, + new Location($openingBracePosition, clone $this->position) + ); + } + + return $this->parseArgumentOptions( + $nestingLevel, + $expectingCloseTag, + $value, + $openingBracePosition + ); + + default: + return $this->error( + ErrorKind::MALFORMED_ARGUMENT, + new Location($openingBracePosition, clone $this->position) + ); + } + } + + /** + * Advance the parser until the end of the identifier, if it is currently on + * an identifier character. Return an empty string otherwise. + * + * @return array{ value: string, location: Location} + */ + private function parseIdentifierIfPossible(): array + { + $startingPosition = clone $this->position; + + $startOffset = $this->offset(); + $value = Utils::matchIdentifierAtIndex($this->message, $startOffset); + $endOffset = $startOffset + s($value)->length(); + + $this->bumpTo($endOffset); + + $endPosition = clone $this->position; + $location = new Location($startingPosition, $endPosition); + + return ['value' => $value, 'location' => $location]; + } + + private function parseArgumentOptions( + int $nestingLevel, + bool $expectingCloseTag, + string $value, + Position $openingBracePosition + ): array { + // Parse this range: + // {name, type, style} + // ^---^ + $typeStartPosition = clone $this->position; + $argType = $this->parseIdentifierIfPossible()['value']; + $typeEndPosition = clone $this->position; + + switch ($argType) { + case '': + // Expecting a style string number, date, time, plural, selectordinal, or select. + return $this->error( + ErrorKind::EXPECT_ARGUMENT_TYPE, + new Location($typeStartPosition, $typeEndPosition) + ); + case 'number': + case 'date': + case 'time': + // Parse this range: + // {name, number, style} + // ^-------^ + $this->bumpSpace(); + /** @var array{style: string, styleLocation: Location}|null */ + $styleAndLocation = null; + + if ($this->bumpIf(',')) { + $this->bumpSpace(); + + $styleStartPosition = clone $this->position; + $result = $this->parseSimpleArgStyleIfPossible(); + if ($result['err']) { + return $result; + } + $style = s($result['val'])->trimEnd(); + + if (0 === $style->length()) { + return $this->error( + ErrorKind::EXPECT_ARGUMENT_STYLE, + new Location(clone $this->position, clone $this->position) + ); + } + + $styleLocation = new Location( + $styleStartPosition, + clone $this->position + ); + $styleAndLocation = [ + 'style' => $style->toString(), + 'styleLocation' => $styleLocation, + ]; + } + + $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); + if ($argCloseResult['err']) { + return $argCloseResult; + } + + $location = new Location( + $openingBracePosition, + clone $this->position + ); + + // Extract style or skeleton + if ($styleAndLocation && s($styleAndLocation['style'] ?? '')->startsWith('::')) { + // Skeleton starts with `::`. + $skeleton = s($styleAndLocation['style'])->slice(2)->trimStart()->toString(); + + if ('number' === $argType) { + $result = $this->parseNumberSkeletonFromString( + $skeleton, + $styleAndLocation['styleLocation'] + ); + if ($result['err']) { + return $result; + } + + return [ + 'val' => [ + 'type' => Type::NUMBER, + 'value' => $value, + 'location' => $location, + 'style' => $result['val'], + ], + 'err' => null, + ]; + } else { + if (0 === s($skeleton)->length()) { + return $this->error(ErrorKind::EXPECT_DATE_TIME_SKELETON, $location); + } + + $dateTimePattern = $skeleton; + + $style = [ + 'type' => SkeletonType::DATE_TIME, + 'pattern' => $dateTimePattern, + 'location' => $styleAndLocation['styleLocation'], + 'parsedOptions' => [], + ]; + + $type = 'date' === $argType ? Type::DATE : Type::TIME; + + return [ + 'val' => [ + 'type' => $type, + 'value' => $value, + 'location' => $location, + 'style' => $style, + ], + 'err' => null, + ]; + } + } + + // Regular style or no style. + return [ + 'val' => [ + 'type' => 'number' === $argType ? Type::NUMBER : ('date' === $argType ? Type::DATE : Type::TIME), + 'value' => $value, + 'location' => $location, + 'style' => $styleAndLocation['style'] ?? null, + ], + 'err' => null, + ]; + + case 'plural': + case 'selectordinal': + case 'select': + // Parse this range: + // {name, plural, options} + // ^---------^ + $typeEndPosition = clone $this->position; + $this->bumpSpace(); + + if (!$this->bumpIf(',')) { + return $this->error( + ErrorKind::EXPECT_SELECT_ARGUMENT_OPTIONS, + new Location($typeEndPosition, clone $typeEndPosition) + ); + } + $this->bumpSpace(); + + // Parse offset: + // {name, plural, offset:1, options} + // ^-----^ + // + // or the first option: + // + // {name, plural, one {...} other {...}} + // ^--^ + $identifierAndLocation = $this->parseIdentifierIfPossible(); + + $pluralOffset = 0; + if ('select' !== $argType && 'offset' === $identifierAndLocation['value']) { + if (!$this->bumpIf(':')) { + return $this->error( + ErrorKind::EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE, + new Location(clone $this->position, clone $this->position) + ); + } + $this->bumpSpace(); + $result = $this->tryParseDecimalInteger( + ErrorKind::EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE, + ErrorKind::INVALID_PLURAL_ARGUMENT_OFFSET_VALUE + ); + if ($result['err']) { + return $result; + } + + // Parse another identifier for option parsing + $this->bumpSpace(); + $identifierAndLocation = $this->parseIdentifierIfPossible(); + + $pluralOffset = $result['val']; + } + + $optionsResult = $this->tryParsePluralOrSelectOptions( + $nestingLevel, + $argType, + $expectingCloseTag, + $identifierAndLocation + ); + if ($optionsResult['err']) { + return $optionsResult; + } + + $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); + if ($argCloseResult['err']) { + return $argCloseResult; + } + + $location = new Location( + $openingBracePosition, + clone $this->position + ); + + if ('select' === $argType) { + return [ + 'val' => [ + 'type' => Type::SELECT, + 'value' => $value, + 'options' => $optionsResult['val'], + 'location' => $location, + ], + 'err' => null, + ]; + } else { + return [ + 'val' => [ + 'type' => Type::PLURAL, + 'value' => $value, + 'offset' => $pluralOffset, + 'options' => $optionsResult['val'], + 'pluralType' => 'plural' === $argType ? 'cardinal' : 'ordinal', + 'location' => $location, + ], + 'err' => null, + ]; + } + + // no break + default: + return $this->error( + ErrorKind::INVALID_ARGUMENT_TYPE, + new Location($typeStartPosition, $typeEndPosition) + ); + } + } + + private function tryParseArgumentClose( + Position $openingBracePosition + ): array { + // Parse: {value, number, ::currency/GBP } + // + if ($this->isEOF() || 125 !== $this->char() /* `}` */) { + return $this->error( + ErrorKind::EXPECT_ARGUMENT_CLOSING_BRACE, + new Location($openingBracePosition, clone $this->position) + ); + } + $this->bump(); // `}` + + return ['val' => null, 'err' => null]; + } + + /** + * See: https://github.com/unicode-org/icu/blob/af7ed1f6d2298013dc303628438ec4abe1f16479/icu4c/source/common/messagepattern.cpp#L659. + */ + private function parseSimpleArgStyleIfPossible(): array + { + $nestedBraces = 0; + + $startPosition = clone $this->position; + while (!$this->isEOF()) { + $ch = $this->char(); + switch ($ch) { + case 39 /* `'` */ : + // Treat apostrophe as quoting but include it in the style part. + // Find the end of the quoted literal text. + $this->bump(); + + $apostrophePosition = clone $this->position; + + if (!$this->bumpUntil("'")) { + return $this->error( + ErrorKind::UNCLOSED_QUOTE_IN_ARGUMENT_STYLE, + new Location($apostrophePosition, clone $this->position) + ); + } + $this->bump(); + break; + + case 123 /* `{` */ : + ++$nestedBraces; + $this->bump(); + break; + + case 125 /* `}` */ : + if ($nestedBraces > 0) { + --$nestedBraces; + } else { + return [ + 'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(), + 'err' => null, + ]; + } + break; + + default: + $this->bump(); + break; + } + } + + return [ + 'val' => s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(), + 'err' => null, + ]; + } + + private function parseNumberSkeletonFromString( + string $skeleton, + Location $location + ) { + $tokens = []; + + return [ + 'val' => [ + 'type' => Type::NUMBER, + 'tokens' => $tokens, + 'location' => $location, + 'parsedOptions' => [], + ], + 'err' => null, + ]; + } + + /** + * @param number nesting_level The current nesting level of messages. + * This can be positive when parsing message fragment in select or plural argument options. + * @param string parent_arg_type The parent argument's type + * @param bool parsed_first_identifier If provided, this is the first identifier-like selector of + * the argument. It is a by-product of a previous parsing attempt. + * @param array{value: string, location: Location} expecting_close_tag If true, this message is directly or indirectly nested inside + * between a pair of opening and closing tags. The nested message will not parse beyond + * the closing tag boundary. + */ + private function tryParsePluralOrSelectOptions( + int $nestingLevel, + string $parentArgType, + bool $expectCloseTag, + array $parsedFirstIdentifier + ): array { + $hasOtherClause = false; + $options = []; + $parsedSelectors = []; + ['value' => $selector, 'location' => $selectorLocation] = $parsedFirstIdentifier; + + // Parse: + // one {one apple} + // ^--^ + while (true) { + if ('' === $selector) { + $startPosition = clone $this->position; + if ('select' !== $parentArgType && $this->bumpIf('=')) { + // Try parse `={number}` selector + $result = $this->tryParseDecimalInteger( + ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR, + ErrorKind::INVALID_PLURAL_ARGUMENT_SELECTOR + ); + if ($result['err']) { + return $result; + } + $selectorLocation = new Location($startPosition, clone $this->position); + $selector = s($this->message)->slice($startPosition->offset, $this->offset() - $startPosition->offset)->toString(); + } else { + break; + } + } + + // Duplicate selector clauses + if (\in_array($selector, $parsedSelectors, true)) { + return $this->error( + 'select' === $parentArgType + ? ErrorKind::DUPLICATE_SELECT_ARGUMENT_SELECTOR + : ErrorKind::DUPLICATE_PLURAL_ARGUMENT_SELECTOR, + $selectorLocation + ); + } + + if ('other' === $selector) { + $hasOtherClause = true; + } + + // Parse: + // one {one apple} + // ^----------^ + $this->bumpSpace(); + $openingBracePosition = clone $this->position; + if (!$this->bumpIf('{')) { + return $this->error( + 'select' === $parentArgType + ? ErrorKind::EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT + : ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT, + new Location(clone $this->position, clone $this->position) + ); + } + + $fragmentResult = $this->parseMessage( + $nestingLevel + 1, + $parentArgType, + $expectCloseTag + ); + if ($fragmentResult['err']) { + return $fragmentResult; + } + $argCloseResult = $this->tryParseArgumentClose($openingBracePosition); + if ($argCloseResult['err']) { + return $argCloseResult; + } + + $options[$selector] = [ + 'value' => $fragmentResult['val'], + 'location' => new Location($openingBracePosition, clone $this->position), + ]; + + // Keep track of the existing selectors + $parsedSelectors[] = $selector; + + // Prep next selector clause. + $this->bumpSpace(); + ['value' => $selector, 'location' => $selectorLocation] = $this->parseIdentifierIfPossible(); + } + + if (0 === \count($options)) { + return $this->error( + 'select' === $parentArgType + ? ErrorKind::EXPECT_SELECT_ARGUMENT_SELECTOR + : ErrorKind::EXPECT_PLURAL_ARGUMENT_SELECTOR, + new Location(clone $this->position, clone $this->position) + ); + } + + if ($this->requiresOtherClause && !$hasOtherClause) { + return $this->error( + ErrorKind::MISSING_OTHER_CLAUSE, + new Location(clone $this->position, clone $this->position) + ); + } + + return [ + 'val' => $options, + 'err' => null, + ]; + } + + /** + * @param ErrorKind::* $expectNumberError + * @param ErrorKind::* $invalidNumberError + */ + private function tryParseDecimalInteger( + string $expectNumberError, + string $invalidNumberError, + ): array { + $sign = 1; + $startingPosition = clone $this->position; + + if ($this->bumpIf('+')) { + // no-op + } elseif ($this->bumpIf('-')) { + $sign = -1; + } + + $hasDigits = false; + $decimal = 0; + while (!$this->isEOF()) { + $ch = $this->char(); + if ($ch >= 48 /* `0` */ && $ch <= 57 /* `9` */) { + $hasDigits = true; + $decimal = $decimal * 10 + ($ch - 48); + $this->bump(); + } else { + break; + } + } + + $location = new Location($startingPosition, clone $this->position); + + if (!$hasDigits) { + return $this->error($expectNumberError, $location); + } + + $decimal *= $sign; + if (!Utils::isSafeInteger($decimal)) { + return $this->error($invalidNumberError, $location); + } + + return [ + 'val' => $decimal, + 'err' => null, + ]; + } + + private function offset(): int + { + return $this->position->offset; + } + + private function isEOF(): bool + { + return $this->offset() === s($this->message)->length(); + } + + /** + * Return the code point at the current position of the parser. + * Throws if the index is out of bound. + * + * @throws \Exception + */ + private function char(): int + { + $message = s($this->message); + + $offset = $this->position->offset; + if ($offset >= $message->length()) { + throw new \OutOfBoundsException(); + } + + $code = $message->slice($offset, 1)->codePointsAt(0)[0] ?? null; + if (null === $code) { + throw new \Exception("Offset {$offset} is at invalid UTF-16 code unit boundary"); + } + + return $code; + } + + /** + * @param ErrorKind::* + * + * @return array{ val: null, err: array{ kind: ErrorKind::*, location: Location, message: string } } + */ + private function error(string $kind, Location $location): array + { + return [ + 'val' => null, + 'err' => [ + 'kind' => $kind, + 'location' => $location, + 'message' => $this->message, + ], + ]; + } + + /** + * Bump the parser to the next UTF-16 code unit. + */ + private function bump(): void + { + if ($this->isEOF()) { + return; + } + $code = $this->char(); + if (10 === $code /* '\n' */) { + ++$this->position->line; + $this->position->column = 1; + ++$this->position->offset; + } else { + ++$this->position->column; + ++$this->position->offset; + } + } + + /** + * If the substring starting at the current position of the parser has + * the given prefix, then bump the parser to the character immediately + * following the prefix and return true. Otherwise, don't bump the parser + * and return false. + */ + private function bumpIf(string $prefix): bool + { + if (s($this->message)->slice($this->offset())->startsWith($prefix)) { + for ($i = 0, $len = \strlen($prefix); $i < $len; ++$i) { + $this->bump(); + } + + return true; + } + + return false; + } + + /** + * Bump the parser until the pattern character is found and return `true`. + * Otherwise bump to the end of the file and return `false`. + */ + private function bumpUntil(string $pattern): bool + { + $currentOffset = $this->offset(); + $index = s($this->message)->indexOf($pattern, $currentOffset); + if ($index >= 0) { + $this->bumpTo($index); + + return true; + } else { + $this->bumpTo(s($this->message)->length()); + + return false; + } + } + + /** + * Bump the parser to the target offset. + * If target offset is beyond the end of the input, bump the parser to the end of the input. + * + * @throws \Exception + */ + private function bumpTo(int $targetOffset) + { + if ($this->offset() > $targetOffset) { + throw new \Exception(sprintf('targetOffset %s must be greater than or equal to the current offset %d', $targetOffset, $this->offset())); + } + + $targetOffset = min($targetOffset, s($this->message)->length()); + while (true) { + $offset = $this->offset(); + if ($offset === $targetOffset) { + break; + } + if ($offset > $targetOffset) { + throw new \Exception("targetOffset {$targetOffset} is at invalid UTF-16 code unit boundary"); + } + + $this->bump(); + if ($this->isEOF()) { + break; + } + } + } + + /** advance the parser through all whitespace to the next non-whitespace code unit. */ + private function bumpSpace() + { + while (!$this->isEOF() && Utils::isWhiteSpace($this->char())) { + $this->bump(); + } + } + + /** + * Peek at the *next* Unicode codepoint in the input without advancing the parser. + * If the input has been exhausted, then this returns null. + */ + private function peek(): ?int + { + if ($this->isEOF()) { + return null; + } + + $code = $this->char(); + $offset = $this->offset(); + $nextCodes = s($this->message)->codePointsAt($offset + ($code >= 0x10000 ? 2 : 1)); + + return $nextCodes[0] ?? null; + } +} diff --git a/src/Translator/src/Intl/Location.php b/src/Translator/src/Intl/Location.php new file mode 100644 index 00000000000..024d10e7370 --- /dev/null +++ b/src/Translator/src/Intl/Location.php @@ -0,0 +1,20 @@ +start = $start; + $this->end = $end; + } +} diff --git a/src/Translator/src/Intl/Position.php b/src/Translator/src/Intl/Position.php new file mode 100644 index 00000000000..6a2266e6f5c --- /dev/null +++ b/src/Translator/src/Intl/Position.php @@ -0,0 +1,24 @@ +offset = $offset; + $this->line = $line; + $this->column = $column; + } +} diff --git a/src/Translator/src/Intl/SkeletonType.php b/src/Translator/src/Intl/SkeletonType.php new file mode 100644 index 00000000000..3f7abf62f2e --- /dev/null +++ b/src/Translator/src/Intl/SkeletonType.php @@ -0,0 +1,18 @@ += 97 && $codepoint <= 122) || + ($codepoint >= 65 && $codepoint <= 90) + ; + } + + public static function isAlphaOrSlash(int $codepoint): bool + { + return self::isAlpha($codepoint) || 47 === $codepoint; /* '/' */ + } + + /** See `parseTag` function docs. */ + public static function isPotentialElementNameChar(int $c): bool + { + return + 45 === $c /* '-' */ || + 46 === $c /* '.' */ || + ($c >= 48 && $c <= 57) /* 0..9 */ || + 95 === $c /* '_' */ || + ($c >= 97 && $c <= 122) /* a..z */ || + ($c >= 65 && $c <= 90) /* A..Z */ || + 0xB7 == $c || + ($c >= 0xC0 && $c <= 0xD6) || + ($c >= 0xD8 && $c <= 0xF6) || + ($c >= 0xF8 && $c <= 0x37D) || + ($c >= 0x37F && $c <= 0x1FFF) || + ($c >= 0x200C && $c <= 0x200D) || + ($c >= 0x203F && $c <= 0x2040) || + ($c >= 0x2070 && $c <= 0x218F) || + ($c >= 0x2C00 && $c <= 0x2FEF) || + ($c >= 0x3001 && $c <= 0xD7FF) || + ($c >= 0xF900 && $c <= 0xFDCF) || + ($c >= 0xFDF0 && $c <= 0xFFFD) || + ($c >= 0x10000 && $c <= 0xEFFFF) + ; + } + + /** + * Code point equivalent of regex `\p{White_Space}`. + * From: https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt. + */ + public static function isWhiteSpace(int $c) + { + return + ($c >= 0x0009 && $c <= 0x000D) || + 0x0020 === $c || + 0x0085 === $c || + ($c >= 0x200E && $c <= 0x200F) || + 0x2028 === $c || + 0x2029 === $c + ; + } + + /** + * Code point equivalent of regex `\p{Pattern_Syntax}`. + * See https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt. + */ + public static function isPatternSyntax(int $c): bool + { + return + ($c >= 0x0021 && $c <= 0x0023) || + 0x0024 === $c || + ($c >= 0x0025 && $c <= 0x0027) || + 0x0028 === $c || + 0x0029 === $c || + 0x002A === $c || + 0x002B === $c || + 0x002C === $c || + 0x002D === $c || + ($c >= 0x002E && $c <= 0x002F) || + ($c >= 0x003A && $c <= 0x003B) || + ($c >= 0x003C && $c <= 0x003E) || + ($c >= 0x003F && $c <= 0x0040) || + 0x005B === $c || + 0x005C === $c || + 0x005D === $c || + 0x005E === $c || + 0x0060 === $c || + 0x007B === $c || + 0x007C === $c || + 0x007D === $c || + 0x007E === $c || + 0x00A1 === $c || + ($c >= 0x00A2 && $c <= 0x00A5) || + 0x00A6 === $c || + 0x00A7 === $c || + 0x00A9 === $c || + 0x00AB === $c || + 0x00AC === $c || + 0x00AE === $c || + 0x00B0 === $c || + 0x00B1 === $c || + 0x00B6 === $c || + 0x00BB === $c || + 0x00BF === $c || + 0x00D7 === $c || + 0x00F7 === $c || + ($c >= 0x2010 && $c <= 0x2015) || + ($c >= 0x2016 && $c <= 0x2017) || + 0x2018 === $c || + 0x2019 === $c || + 0x201A === $c || + ($c >= 0x201B && $c <= 0x201C) || + 0x201D === $c || + 0x201E === $c || + 0x201F === $c || + ($c >= 0x2020 && $c <= 0x2027) || + ($c >= 0x2030 && $c <= 0x2038) || + 0x2039 === $c || + 0x203A === $c || + ($c >= 0x203B && $c <= 0x203E) || + ($c >= 0x2041 && $c <= 0x2043) || + 0x2044 === $c || + 0x2045 === $c || + 0x2046 === $c || + ($c >= 0x2047 && $c <= 0x2051) || + 0x2052 === $c || + 0x2053 === $c || + ($c >= 0x2055 && $c <= 0x205E) || + ($c >= 0x2190 && $c <= 0x2194) || + ($c >= 0x2195 && $c <= 0x2199) || + ($c >= 0x219A && $c <= 0x219B) || + ($c >= 0x219C && $c <= 0x219F) || + 0x21A0 === $c || + ($c >= 0x21A1 && $c <= 0x21A2) || + 0x21A3 === $c || + ($c >= 0x21A4 && $c <= 0x21A5) || + 0x21A6 === $c || + ($c >= 0x21A7 && $c <= 0x21AD) || + 0x21AE === $c || + ($c >= 0x21AF && $c <= 0x21CD) || + ($c >= 0x21CE && $c <= 0x21CF) || + ($c >= 0x21D0 && $c <= 0x21D1) || + 0x21D2 === $c || + 0x21D3 === $c || + 0x21D4 === $c || + ($c >= 0x21D5 && $c <= 0x21F3) || + ($c >= 0x21F4 && $c <= 0x22FF) || + ($c >= 0x2300 && $c <= 0x2307) || + 0x2308 === $c || + 0x2309 === $c || + 0x230A === $c || + 0x230B === $c || + ($c >= 0x230C && $c <= 0x231F) || + ($c >= 0x2320 && $c <= 0x2321) || + ($c >= 0x2322 && $c <= 0x2328) || + 0x2329 === $c || + 0x232A === $c || + ($c >= 0x232B && $c <= 0x237B) || + 0x237C === $c || + ($c >= 0x237D && $c <= 0x239A) || + ($c >= 0x239B && $c <= 0x23B3) || + ($c >= 0x23B4 && $c <= 0x23DB) || + ($c >= 0x23DC && $c <= 0x23E1) || + ($c >= 0x23E2 && $c <= 0x2426) || + ($c >= 0x2427 && $c <= 0x243F) || + ($c >= 0x2440 && $c <= 0x244A) || + ($c >= 0x244B && $c <= 0x245F) || + ($c >= 0x2500 && $c <= 0x25B6) || + 0x25B7 === $c || + ($c >= 0x25B8 && $c <= 0x25C0) || + 0x25C1 === $c || + ($c >= 0x25C2 && $c <= 0x25F7) || + ($c >= 0x25F8 && $c <= 0x25FF) || + ($c >= 0x2600 && $c <= 0x266E) || + 0x266F === $c || + ($c >= 0x2670 && $c <= 0x2767) || + 0x2768 === $c || + 0x2769 === $c || + 0x276A === $c || + 0x276B === $c || + 0x276C === $c || + 0x276D === $c || + 0x276E === $c || + 0x276F === $c || + 0x2770 === $c || + 0x2771 === $c || + 0x2772 === $c || + 0x2773 === $c || + 0x2774 === $c || + 0x2775 === $c || + ($c >= 0x2794 && $c <= 0x27BF) || + ($c >= 0x27C0 && $c <= 0x27C4) || + 0x27C5 === $c || + 0x27C6 === $c || + ($c >= 0x27C7 && $c <= 0x27E5) || + 0x27E6 === $c || + 0x27E7 === $c || + 0x27E8 === $c || + 0x27E9 === $c || + 0x27EA === $c || + 0x27EB === $c || + 0x27EC === $c || + 0x27ED === $c || + 0x27EE === $c || + 0x27EF === $c || + ($c >= 0x27F0 && $c <= 0x27FF) || + ($c >= 0x2800 && $c <= 0x28FF) || + ($c >= 0x2900 && $c <= 0x2982) || + 0x2983 === $c || + 0x2984 === $c || + 0x2985 === $c || + 0x2986 === $c || + 0x2987 === $c || + 0x2988 === $c || + 0x2989 === $c || + 0x298A === $c || + 0x298B === $c || + 0x298C === $c || + 0x298D === $c || + 0x298E === $c || + 0x298F === $c || + 0x2990 === $c || + 0x2991 === $c || + 0x2992 === $c || + 0x2993 === $c || + 0x2994 === $c || + 0x2995 === $c || + 0x2996 === $c || + 0x2997 === $c || + 0x2998 === $c || + ($c >= 0x2999 && $c <= 0x29D7) || + 0x29D8 === $c || + 0x29D9 === $c || + 0x29DA === $c || + 0x29DB === $c || + ($c >= 0x29DC && $c <= 0x29FB) || + 0x29FC === $c || + 0x29FD === $c || + ($c >= 0x29FE && $c <= 0x2AFF) || + ($c >= 0x2B00 && $c <= 0x2B2F) || + ($c >= 0x2B30 && $c <= 0x2B44) || + ($c >= 0x2B45 && $c <= 0x2B46) || + ($c >= 0x2B47 && $c <= 0x2B4C) || + ($c >= 0x2B4D && $c <= 0x2B73) || + ($c >= 0x2B74 && $c <= 0x2B75) || + ($c >= 0x2B76 && $c <= 0x2B95) || + 0x2B96 === $c || + ($c >= 0x2B97 && $c <= 0x2BFF) || + ($c >= 0x2E00 && $c <= 0x2E01) || + 0x2E02 === $c || + 0x2E03 === $c || + 0x2E04 === $c || + 0x2E05 === $c || + ($c >= 0x2E06 && $c <= 0x2E08) || + 0x2E09 === $c || + 0x2E0A === $c || + 0x2E0B === $c || + 0x2E0C === $c || + 0x2E0D === $c || + ($c >= 0x2E0E && $c <= 0x2E16) || + 0x2E17 === $c || + ($c >= 0x2E18 && $c <= 0x2E19) || + 0x2E1A === $c || + 0x2E1B === $c || + 0x2E1C === $c || + 0x2E1D === $c || + ($c >= 0x2E1E && $c <= 0x2E1F) || + 0x2E20 === $c || + 0x2E21 === $c || + 0x2E22 === $c || + 0x2E23 === $c || + 0x2E24 === $c || + 0x2E25 === $c || + 0x2E26 === $c || + 0x2E27 === $c || + 0x2E28 === $c || + 0x2E29 === $c || + ($c >= 0x2E2A && $c <= 0x2E2E) || + 0x2E2F === $c || + ($c >= 0x2E30 && $c <= 0x2E39) || + ($c >= 0x2E3A && $c <= 0x2E3B) || + ($c >= 0x2E3C && $c <= 0x2E3F) || + 0x2E40 === $c || + 0x2E41 === $c || + 0x2E42 === $c || + ($c >= 0x2E43 && $c <= 0x2E4F) || + ($c >= 0x2E50 && $c <= 0x2E51) || + 0x2E52 === $c || + ($c >= 0x2E53 && $c <= 0x2E7F) || + ($c >= 0x3001 && $c <= 0x3003) || + 0x3008 === $c || + 0x3009 === $c || + 0x300A === $c || + 0x300B === $c || + 0x300C === $c || + 0x300D === $c || + 0x300E === $c || + 0x300F === $c || + 0x3010 === $c || + 0x3011 === $c || + ($c >= 0x3012 && $c <= 0x3013) || + 0x3014 === $c || + 0x3015 === $c || + 0x3016 === $c || + 0x3017 === $c || + 0x3018 === $c || + 0x3019 === $c || + 0x301A === $c || + 0x301B === $c || + 0x301C === $c || + 0x301D === $c || + ($c >= 0x301E && $c <= 0x301F) || + 0x3020 === $c || + 0x3030 === $c || + 0xFD3E === $c || + 0xFD3F === $c || + ($c >= 0xFE45 && $c <= 0xFE46) + ; + } + + public static function fromCodePoint(int ...$codePoints): string + { + $elements = ''; + $length = \count($codePoints); + $i = 0; + while ($length > $i) { + $code = $codePoints[$i++]; + if ($code > 0x10FFFF) { + throw RangeError($code + ' is not a valid code point'); + } + + $elements .= mb_chr($code, 'UTF-8'); + } + + return $elements; + } + + public static function matchIdentifierAtIndex(string $s, int $index): string + { + $match = []; + + while (true) { + $c = s($s)->codePointsAt($index)[0] ?? null; + if (null === $c || self::isWhiteSpace($c) || self::isPatternSyntax($c)) { + break; + } + + $match[] = $c; + $index += $c >= 0x10000 ? 2 : 1; + } + + return self::fromCodePoint(...$match); + } + + public static function isSafeInteger(mixed $value): bool + { + return \is_int($value) && is_finite($value) && abs($value) <= \PHP_INT_MAX; + } +} diff --git a/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php new file mode 100644 index 00000000000..93d2b1d5129 --- /dev/null +++ b/src/Translator/src/MessageParameters/Extractor/ExtractorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\MessageParameters\Extractor; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +interface ExtractorInterface +{ + /** + * @return array}|array{ type: 'date' }> + * + * @throws \Exception + */ + public function extract(string $message): array; +} diff --git a/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php new file mode 100644 index 00000000000..f90e61e3186 --- /dev/null +++ b/src/Translator/src/MessageParameters/Extractor/IntlMessageParametersExtractor.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\MessageParameters\Extractor; + +use Symfony\UX\Translator\Intl\IntlMessageParser; +use Symfony\UX\Translator\Intl\Type; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +final class IntlMessageParametersExtractor implements ExtractorInterface +{ + public function extract(string $message): array + { + $parameters = []; + + $intlMessageParser = new IntlMessageParser($message); + $ast = $intlMessageParser->parse(); + if ($ast['err']) { + throw new \InvalidArgumentException(sprintf('The message "%s" is not a valid Intl message.', $message)); + } + + $nodes = $ast['val']; + + while ([] !== $nodes) { + $node = array_shift($nodes); + + switch ($node['type']) { + case Type::LITERAL: + break; + + case Type::ARGUMENT: + $parameters[$node['value']] = ['type' => 'string']; + break; + + case Type::NUMBER: + $parameters[$node['value']] = ['type' => 'number']; + break; + + case Type::DATE: + case Type::TIME: + $parameters[$node['value']] = ['type' => 'date']; + break; + + case Type::SELECT: + $parameters[$node['value']] = [ + 'type' => 'string', + 'values' => array_keys($node['options']), + ]; + + foreach ($node['options'] as $option) { + foreach ($option['value'] as $nodeValue) { + $nodes[] = $nodeValue; + } + } + + break; + + case Type::PLURAL: + $parameters[$node['value']] = [ + 'type' => 'number', + ]; + + foreach ($node['options'] as $option) { + foreach ($option['value'] as $nodeValue) { + $nodes[] = $nodeValue; + } + } + + break; + } + } + + return $parameters; + } +} diff --git a/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php new file mode 100644 index 00000000000..289c32db171 --- /dev/null +++ b/src/Translator/src/MessageParameters/Extractor/MessageParametersExtractor.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\MessageParameters\Extractor; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +final class MessageParametersExtractor implements ExtractorInterface +{ + private const RE_PARAMETER = '/(%\w+%)|({{ \w+ }})/'; + + public function extract(string $message): array + { + $parameters = []; + + if (false !== preg_match_all(self::RE_PARAMETER, $message, $matches)) { + foreach ($matches[0] as $match) { + $parameters[$match] = [ + 'type' => '%count%' === $match ? 'number' : 'string', + ]; + } + } + + return $parameters; + } +} diff --git a/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php new file mode 100644 index 00000000000..523c08f647b --- /dev/null +++ b/src/Translator/src/MessageParameters/Printer/TypeScriptMessageParametersPrinter.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\MessageParameters\Printer; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +final class TypeScriptMessageParametersPrinter +{ + /** + * @param array}|array{ type: 'date' }> $parameters + */ + public function print(array $parameters): string + { + if ([] === $parameters) { + return 'NoParametersType'; + } + + $type = '{ '; + foreach ($parameters as $parameterName => $parameter) { + switch ($parameter['type']) { + case 'number': + $value = 'number'; + break; + case 'string': + if (\is_array($parameter['values'] ?? null)) { + $value = implode( + '|', + array_map( + fn (string $val) => 'other' === $val ? 'string' : "'".$val."'", + $parameter['values'] + ) + ); + } else { + $value = 'string'; + } + break; + case 'date': + $value = 'Date'; + break; + default: + throw new \InvalidArgumentException(sprintf('Unknown type "%s" for parameter "%s"', $parameter['type'], $parameterName)); + } + + $type .= sprintf("'%s': %s, ", $parameterName, $value); + } + + $type = rtrim($type, ', '); + $type .= ' }'; + + return $type; + } +} diff --git a/src/Translator/src/TranslationsDumper.php b/src/Translator/src/TranslationsDumper.php new file mode 100644 index 00000000000..4e8bdd33582 --- /dev/null +++ b/src/Translator/src/TranslationsDumper.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor; +use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; +use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; + +use function Symfony\Component\String\s; + +/** + * @author Hugo Alliaume + * + * @final + * + * @experimental + * + * @phpstan-type Domain string + * @phpstan-type Locale string + * @phpstan-type MessageId string + */ +class TranslationsDumper +{ + public function __construct( + private string $dumpDir, + private MessageParametersExtractor $messageParametersExtractor, + private IntlMessageParametersExtractor $intlMessageParametersExtractor, + private TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter, + private Filesystem $filesystem, + ) { + } + + public function dump(MessageCatalogueInterface ...$catalogues): void + { + $this->filesystem->mkdir($this->dumpDir); + $this->filesystem->remove($this->dumpDir.'/index.js'); + $this->filesystem->remove($this->dumpDir.'/index.d.ts'); + $this->filesystem->remove($this->dumpDir.'/configuration.js'); + $this->filesystem->remove($this->dumpDir.'/configuration.d.ts'); + + $translationsJs = ''; + $translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n"; + + foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) { + $constantName = s($translationId)->ascii()->snake()->upper()->toString(); + + $translationsJs .= sprintf( + "export const %s = %s;\n", + $constantName, + json_encode([ + 'id' => $translationId, + 'translations' => $translationsByDomainAndLocale, + ], \JSON_THROW_ON_ERROR), + ); + $translationsTs .= sprintf( + "export declare const %s: %s;\n", + $constantName, + $this->getTranslationsTypeScriptTypeDefinition($translationsByDomainAndLocale) + ); + } + + $this->filesystem->dumpFile($this->dumpDir.'/index.js', $translationsJs); + $this->filesystem->dumpFile($this->dumpDir.'/index.d.ts', $translationsTs); + $this->filesystem->dumpFile($this->dumpDir.'/configuration.js', sprintf( + "export const localeFallbacks = %s;\n", + json_encode($this->getLocaleFallbacks(...$catalogues), \JSON_THROW_ON_ERROR) + )); + $this->filesystem->dumpFile($this->dumpDir.'/configuration.d.ts', <<<'TS' +import { LocaleType } from '@symfony/ux-translator'; + +export declare const localeFallbacks: Record; +TS + ); + } + + /** + * @return array>> + */ + private function getTranslations(MessageCatalogueInterface ...$catalogues): array + { + $translations = []; + + foreach ($catalogues as $catalogue) { + $locale = $catalogue->getLocale(); + foreach ($catalogue->getDomains() as $domain) { + foreach ($catalogue->all($domain) as $id => $message) { + $realDomain = $catalogue->has($id, $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX) + ? $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX + : $domain; + + $translations[$id] ??= []; + $translations[$id][$realDomain] ??= []; + $translations[$id][$realDomain][$locale] = $message; + } + } + } + + return $translations; + } + + /** + * @param array> $translationsByDomainAndLocale + * + * @throws \Exception + */ + private function getTranslationsTypeScriptTypeDefinition(array $translationsByDomainAndLocale): string + { + $parametersTypes = []; + $locales = []; + + foreach ($translationsByDomainAndLocale as $domain => $translationsByLocale) { + foreach ($translationsByLocale as $locale => $translation) { + try { + $parameters = str_ends_with($domain, MessageCatalogueInterface::INTL_DOMAIN_SUFFIX) + ? $this->intlMessageParametersExtractor->extract($translation) + : $this->messageParametersExtractor->extract($translation); + } catch (\Throwable $e) { + throw new \Exception(sprintf('Error while extracting parameters from message "%s" in domain "%s" and locale "%s".', $translation, $domain, $locale), previous: $e); + } + + $parametersTypes[$domain] = $this->typeScriptMessageParametersPrinter->print($parameters); + + $locales[] = $locale; + } + } + + return sprintf( + 'Message<{ %s }, %s>', + implode(', ', array_reduce( + array_keys($parametersTypes), + fn (array $carry, string $domain) => [ + ...$carry, + sprintf("'%s': { parameters: %s }", $domain, $parametersTypes[$domain]), + ], + [], + )), + implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))), + ); + } + + private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): array + { + $localesFallbacks = []; + + foreach ($catalogues as $catalogue) { + $localesFallbacks[$catalogue->getLocale()] = $catalogue->getFallbackCatalogue()?->getLocale(); + } + + return $localesFallbacks; + } +} diff --git a/src/Translator/src/TranslatorBundle.php b/src/Translator/src/TranslatorBundle.php new file mode 100644 index 00000000000..da7226c1d61 --- /dev/null +++ b/src/Translator/src/TranslatorBundle.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Hugo Alliaume + * + * @final + * + * @experimental + */ +class TranslatorBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php b/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php new file mode 100644 index 00000000000..2931c8b2223 --- /dev/null +++ b/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php @@ -0,0 +1,49 @@ +addCatalogue( + new MessageCatalogue('en', [ + 'messages' => [ + 'foo' => 'bar', + ], + ]) + ); + + $translationsDumperMock = $this->createMock(TranslationsDumper::class); + $translationsDumperMock + ->expects($this->once()) + ->method('dump') + ->with(...$translatorBag->getCatalogues()); + + $translationsCacheWarmer = new TranslationsCacheWarmer( + $translatorBag, + $translationsDumperMock + ); + + $translationsCacheWarmer->warmUp(self::$cacheDir); + } +} diff --git a/src/Translator/tests/Intl/IntlMessageParserTest.php b/src/Translator/tests/Intl/IntlMessageParserTest.php new file mode 100644 index 00000000000..76ee14d6082 --- /dev/null +++ b/src/Translator/tests/Intl/IntlMessageParserTest.php @@ -0,0 +1,383 @@ +parse()); + } + + public function provideParse() + { + yield 'no parameters' => [ + 'Hello world!', + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'Hello world!', + 'location' => new Location(new Position(0, 1, 1), new Position(12, 1, 13)), + ], + ], + 'err' => null, + ], + ]; + + yield 'with emoji' => [ + "We hope we've met your expectations 😊", + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => "We hope we've met your expectations 😊", + 'location' => new Location(new Position(0, 1, 1), new Position(37, 1, 38)), + ], + ], + 'err' => null, + ], + ]; + + yield 'with HTML' => [ + 'Hello world!', + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'Hello world!', + 'location' => new Location(new Position(0, 1, 1), new Position(19, 1, 20)), + ], + ], + 'err' => null, + ], + ]; + + yield 'one parameter' => [ + 'Hello {name}!', + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'Hello ', + 'location' => new Location(new Position(0, 1, 1), new Position(6, 1, 7)), + ], + [ + 'type' => Type::ARGUMENT, + 'value' => 'name', + 'location' => new Location(new Position(6, 1, 7), new Position(12, 1, 13)), + ], + [ + 'type' => Type::LITERAL, + 'value' => '!', + 'location' => new Location(new Position(12, 1, 13), new Position(13, 1, 14)), + ], + ], + 'err' => null, + ], + ]; + + yield 'multiples parameters' => [ + 'Hello {firstName}, welcome to {hotelName}', + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'Hello ', + 'location' => new Location(new Position(0, 1, 1), new Position(6, 1, 7)), + ], + [ + 'type' => Type::ARGUMENT, + 'value' => 'firstName', + 'location' => new Location(new Position(6, 1, 7), new Position(17, 1, 18)), + ], + [ + 'type' => Type::LITERAL, + 'value' => ', welcome to ', + 'location' => new Location(new Position(17, 1, 18), new Position(30, 1, 31)), + ], + [ + 'type' => Type::ARGUMENT, + 'value' => 'hotelName', + 'location' => new Location(new Position(30, 1, 31), new Position(41, 1, 42)), + ], + ], + 'err' => null, + ], + ]; + + yield 'plural' => [ + <<<'EOT' +You have {itemCount, plural, + =0 {no items} + one {1 item} + other {{itemCount} items} +}. +EOT, + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'You have ', + 'location' => new Location(new Position(0, 1, 1), new Position(9, 1, 10)), + ], + [ + 'type' => Type::PLURAL, + 'value' => 'itemCount', + 'offset' => 0, + 'options' => [ + '=0' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'no items', + 'location' => new Location(new Position(37, 2, 9), new Position(45, 2, 17)), + ], + ], + 'location' => new Location(new Position(36, 2, 8), new Position(46, 2, 18)), + ], + 'one' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => '1 item', + 'location' => new Location(new Position(56, 3, 10), new Position(62, 3, 16)), + ], + ], + 'location' => new Location(new Position(55, 3, 9), new Position(63, 3, 17)), + ], + 'other' => [ + 'value' => [ + [ + 'type' => Type::ARGUMENT, + 'value' => 'itemCount', + 'location' => new Location(new Position(75, 4, 12), new Position(86, 4, 23)), + ], + [ + 'type' => Type::LITERAL, + 'value' => ' items', + 'location' => new Location(new Position(86, 4, 23), new Position(92, 4, 29)), + ], + ], + 'location' => new Location(new Position(74, 4, 11), new Position(93, 4, 30)), + ], + ], + 'pluralType' => 'cardinal', + 'location' => new Location(new Position(9, 1, 10), new Position(95, 5, 2)), + ], + [ + 'type' => Type::LITERAL, + 'value' => '.', + 'location' => new Location(new Position(95, 5, 2), new Position(96, 5, 3)), + ], + ], + 'err' => null, + ], + ]; + + yield 'many parameters, plural, select, with HTML' => [ + <<<'EOT' +I have {count, plural, + one{a { + gender, select, + male{male} + female{female} + other{male} + } dog + } + other{many dogs}} and {count, plural, + one{a { + gender, select, + male{male} + female{female} + other{male} + } cat + } + other{many cats}} +EOT, + [ + 'val' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'I have ', + 'location' => new Location(new Position(0, 1, 1), new Position(7, 1, 8)), + ], + [ + 'type' => Type::PLURAL, + 'value' => 'count', + 'offset' => 0, + 'options' => [ + 'one' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'a ', + 'location' => new Location(new Position(31, 2, 9), new Position(33, 2, 11)), + ], + [ + 'type' => Type::SELECT, + 'value' => 'gender', + 'options' => [ + 'male' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'male', + 'location' => new Location(new Position(76, 4, 18), new Position(80, 4, 22)), + ], + ], + 'location' => new Location(new Position(75, 4, 17), new Position(81, 4, 23)), + ], + 'female' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'female', + 'location' => new Location(new Position(101, 5, 20), new Position(107, 5, 26)), + ], + ], + 'location' => new Location(new Position(100, 5, 19), new Position(108, 5, 27)), + ], + 'other' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'male', + 'location' => new Location(new Position(127, 6, 19), new Position(131, 6, 23)), + ], + ], + 'location' => new Location(new Position(126, 6, 18), new Position(132, 6, 24)), + ], + ], + 'location' => new Location(new Position(33, 2, 11), new Position(142, 7, 10)), + ], + [ + 'type' => Type::LITERAL, + 'value' => " dog\n ", + 'location' => new Location(new Position(142, 7, 10), new Position(158, 8, 5)), + ], + ], + 'location' => new Location(new Position(30, 2, 8), new Position(159, 8, 6)), + ], + 'other' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'many dogs', + 'location' => new Location(new Position(170, 9, 11), new Position(179, 9, 20)), + ], + ], + 'location' => new Location(new Position(169, 9, 10), new Position(180, 9, 21)), + ], + ], + 'pluralType' => 'cardinal', + 'location' => new Location(new Position(7, 1, 8), new Position(181, 9, 22)), + ], + [ + 'type' => Type::LITERAL, + 'value' => ' and ', + 'location' => new Location(new Position(181, 9, 22), new Position(186, 9, 27)), + ], + [ + 'type' => Type::PLURAL, + 'value' => 'count', + 'location' => new Location(new Position(186, 9, 27), new Position(402, 17, 26)), + 'offset' => 0, + 'options' => [ + 'one' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'a ', + 'location' => new Location(new Position(214, 10, 13), new Position(216, 10, 15)), + ], + [ + 'type' => Type::SELECT, + 'options' => [ + 'male' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'male', + 'location' => new Location(new Position(267, 12, 22), new Position(271, 12, 26)), + ], + ], + 'location' => new Location(new Position(266, 12, 21), new Position(272, 12, 27)), + ], + 'female' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'female', + 'location' => new Location(new Position(296, 13, 24), new Position(302, 13, 30)), + ], + ], + 'location' => new Location(new Position(295, 13, 23), new Position(303, 13, 31)), + ], + 'other' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'male', + 'location' => new Location(new Position(326, 14, 23), new Position(330, 14, 27)), + ], + ], + 'location' => new Location(new Position(325, 14, 22), new Position(331, 14, 28)), + ], + ], + 'value' => 'gender', + 'location' => new Location(new Position(216, 10, 15), new Position(345, 15, 14)), + ], + [ + 'type' => Type::LITERAL, + 'value' => " cat\n ", + 'location' => new Location(new Position(345, 15, 14), new Position(375, 16, 9)), + ], + ], + 'location' => new Location(new Position(213, 10, 12), new Position(376, 16, 10)), + ], + 'other' => [ + 'value' => [ + [ + 'type' => Type::LITERAL, + 'value' => 'many cats', + 'location' => new Location(new Position(391, 17, 15), new Position(400, 17, 24)), + ], + ], + 'location' => new Location(new Position(390, 17, 14), new Position(401, 17, 25)), + ], + ], + 'pluralType' => 'cardinal', + ], + ], + 'err' => null, + ], + ]; + } + + public function testParseWithUnclosedBracket() + { + $intlMessageParser = new IntlMessageParser('Hello {name!'); + + static::assertEquals([ + 'val' => null, + 'err' => [ + 'kind' => ErrorKind::MALFORMED_ARGUMENT, + 'location' => new Location(new Position(6, 1, 7), new Position(11, 1, 12)), + 'message' => 'Hello {name!', + ], + ], $intlMessageParser->parse()); + } +} diff --git a/src/Translator/tests/Kernel/AppKernelTrait.php b/src/Translator/tests/Kernel/AppKernelTrait.php new file mode 100644 index 00000000000..36c6b158390 --- /dev/null +++ b/src/Translator/tests/Kernel/AppKernelTrait.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests\Kernel; + +/** + * @author Hugo Alliaume + * + * @internal + */ +trait AppKernelTrait +{ + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/translator_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/src/Translator/tests/Kernel/EmptyAppKernel.php b/src/Translator/tests/Kernel/EmptyAppKernel.php new file mode 100644 index 00000000000..2dedb4fdeb0 --- /dev/null +++ b/src/Translator/tests/Kernel/EmptyAppKernel.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests\Kernel; + +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Translator\TranslatorBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class EmptyAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new TranslatorBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + } +} diff --git a/src/Translator/tests/Kernel/FrameworkAppKernel.php b/src/Translator/tests/Kernel/FrameworkAppKernel.php new file mode 100644 index 00000000000..d7b127a6997 --- /dev/null +++ b/src/Translator/tests/Kernel/FrameworkAppKernel.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Translator\TranslatorBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class FrameworkAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TranslatorBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'secret' => '$ecret', + 'test' => true, + 'translator' => [ + 'fallbacks' => ['en'], + ], + 'http_method_override' => false, + ]); + }); + } +} diff --git a/src/Translator/tests/MessageParameters/Extractor/IntlMessageParametersExtractorTest.php b/src/Translator/tests/MessageParameters/Extractor/IntlMessageParametersExtractorTest.php new file mode 100644 index 00000000000..c4f2482cba2 --- /dev/null +++ b/src/Translator/tests/MessageParameters/Extractor/IntlMessageParametersExtractorTest.php @@ -0,0 +1,117 @@ +extract($message)); + } + + public function provideExtract() + { + yield [ + 'Symfony is great!', + [], + ]; + + yield [ + 'Symfony is {what}!', + ['what' => ['type' => 'string']], + ]; + + yield [ + '{framework} is {what}!', + [ + 'framework' => ['type' => 'string'], + 'what' => ['type' => 'string'], + ], + ]; + + yield [ + 'I have {numCats, number} cats.', + ['numCats' => ['type' => 'number']], + ]; + + yield [ + 'Almost {pctBlack, number, ::percent} of my cats are black.', + ['pctBlack' => ['type' => 'number']], + ]; + + yield [ + 'The price of this bagel is {num, number, ::sign-always compact-short currency/GBP}', + ['num' => ['type' => 'number']], + ]; + + yield [ + 'Coupon expires at {expires, time, short}', + ['expires' => ['type' => 'date']], + ]; + + yield [ + << ['type' => 'string', 'values' => ['male', 'female', 'other']], + ], + ]; + + yield [ + << ['type' => 'string', 'values' => ['yes', 'other']], + 'taxRate' => ['type' => 'number'], + ], + ]; + + yield [ + << ['type' => 'string', 'values' => ['female', 'male', 'other']], + 'num_guests' => ['type' => 'number'], + 'host' => ['type' => 'string'], + 'guest' => ['type' => 'string'], + ], + ]; + } +} diff --git a/src/Translator/tests/MessageParameters/Extractor/MessageParametersExtractorTest.php b/src/Translator/tests/MessageParameters/Extractor/MessageParametersExtractorTest.php new file mode 100644 index 00000000000..343396b77be --- /dev/null +++ b/src/Translator/tests/MessageParameters/Extractor/MessageParametersExtractorTest.php @@ -0,0 +1,63 @@ +extract($message), $expectedParameters); + } + + public function provideExtract() + { + yield [ + 'Symfony is great!', + [], + ]; + + yield [ + 'Symfony is %what%!', + ['%what%' => ['type' => 'string']], + ]; + + yield [ + '%framework% is %what%!', + [ + '%framework%' => ['type' => 'string'], + '%what%' => ['type' => 'string'], + ], + ]; + + yield [ + '%framework% have more than %years% years!', + [ + '%framework%' => ['type' => 'string'], + '%years%' => ['type' => 'string'], + ], + ]; + + yield [ + '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', + ['%count%' => ['type' => 'number']], + ]; + + yield [ + 'There is 1 apple|There are %count% apples', + ['%count%' => ['type' => 'number']], + ]; + + yield [ + 'You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.', + ['{{ limit }}' => ['type' => 'string']], + ]; + } +} diff --git a/src/Translator/tests/MessageParameters/Printer/TypeScriptMessageParametersPrinterTest.php b/src/Translator/tests/MessageParameters/Printer/TypeScriptMessageParametersPrinterTest.php new file mode 100644 index 00000000000..1a183c937df --- /dev/null +++ b/src/Translator/tests/MessageParameters/Printer/TypeScriptMessageParametersPrinterTest.php @@ -0,0 +1,111 @@ +print($parameters)); + } + + public function providePrint() + { + yield [ + [], + 'NoParametersType', + ]; + + yield [ + ['%what%' => ['type' => 'string']], + "{ '%what%': string }", + ]; + + yield [ + ['what' => ['type' => 'string']], + "{ 'what': string }", + ]; + + yield [ + [ + 'framework' => ['type' => 'string'], + 'what' => ['type' => 'string'], + ], + "{ 'framework': string, 'what': string }", + ]; + + yield [ + [ + '%framework%' => ['type' => 'string'], + '%what%' => ['type' => 'string'], + ], + "{ '%framework%': string, '%what%': string }", + ]; + + yield [ + [ + '%framework%' => ['type' => 'string'], + '%years%' => ['type' => 'string'], + ], + "{ '%framework%': string, '%years%': string }", + ]; + + yield [ + ['%count%' => ['type' => 'number']], + "{ '%count%': number }", + ]; + + yield [ + ['{{ limit }}' => ['type' => 'string']], + "{ '{{ limit }}': string }", + ]; + + yield [ + ['numCats' => ['type' => 'number']], + "{ 'numCats': number }", + ]; + + yield [ + ['num' => ['type' => 'number']], + "{ 'num': number }", + ]; + + yield [ + ['expires' => ['type' => 'date']], + "{ 'expires': Date }", + ]; + + yield [ + [ + 'gender' => ['type' => 'string', 'values' => ['male', 'female', 'other']], + ], + "{ 'gender': 'male'|'female'|string }", + ]; + + yield [ + [ + 'taxableArea' => ['type' => 'string', 'values' => ['yes', 'other']], + 'taxRate' => ['type' => 'number'], + ], + "{ 'taxableArea': 'yes'|string, 'taxRate': number }", + ]; + + yield [ + [ + 'gender_of_host' => ['type' => 'string', 'values' => ['female', 'male', 'other']], + 'num_guests' => ['type' => 'number'], + 'host' => ['type' => 'string'], + 'guest' => ['type' => 'string'], + ], + "{ 'gender_of_host': 'female'|'male'|string, 'num_guests': number, 'host': string, 'guest': string }", + ]; + } +} diff --git a/src/Translator/tests/TranslationsDumperTest.php b/src/Translator/tests/TranslationsDumperTest.php new file mode 100644 index 00000000000..a3cd9bf2139 --- /dev/null +++ b/src/Translator/tests/TranslationsDumperTest.php @@ -0,0 +1,127 @@ +dump( + new MessageCatalogue('en', [ + 'messages+intl-icu' => [ + 'notification.comment_created' => 'Your post received a comment!', + 'notification.comment_created.description' => 'Your post "{title}" has received a new comment. You can read the comment by following this link', + 'post.num_comments' => '{count, plural, one {# comment} other {# comments}}', + ], + 'messages' => [ + 'symfony.great' => 'Symfony is awesome!', + 'symfony.what' => 'Symfony is %what%!', + 'apples.count.0' => 'There is 1 apple|There are %count% apples', + 'apples.count.1' => '{1} There is one apple|]1,Inf] There are %count% apples', + 'apples.count.2' => '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', + 'apples.count.3' => 'one: There is one apple|more: There are %count% apples', + 'apples.count.4' => 'one: There is one apple|more: There are more than one apple', + 'what.count.1' => '{1} There is one %what%|]1,Inf] There are %count% %what%', + 'what.count.2' => '{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%', + 'what.count.3' => 'one: There is one %what%|more: There are %count% %what%', + 'what.count.4' => 'one: There is one %what%|more: There are more than one %what%', + ], + 'foobar' => [ + 'post.num_comments' => 'There is 1 comment|There are %count% comments', + ], + ]), + new MessageCatalogue('fr', [ + 'messages+intl-icu' => [ + 'notification.comment_created' => 'Votre article a reçu un commentaire !', + 'notification.comment_created.description' => 'Votre article "{title}" a reçu un nouveau commentaire. Vous pouvez lire le commentaire en suivant ce lien', + 'post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}', + ], + 'messages' => [ + 'symfony.great' => 'Symfony est génial !', + 'symfony.what' => 'Symfony est %what%!', + 'apples.count.0' => 'Il y a 1 pomme|Il y a %count% pommes', + 'apples.count.1' => '{1} Il y a une pomme|]1,Inf] Il y a %count% pommes', + 'apples.count.2' => '{0} Il n\'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes', + 'apples.count.3' => 'one: Il y a une pomme|more: Il y a %count% pommes', + 'apples.count.4' => 'one: Il y a une pomme|more: Il y a plus d\'une pomme', + 'what.count.1' => '{1} Il y a une %what%|]1,Inf] Il y a %count% %what%', + 'what.count.2' => '{0} Il n\'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%', + 'what.count.3' => 'one: Il y a une %what%|more: Il y a %count% %what%', + 'what.count.4' => 'one: Il y a une %what%|more: Il y a more than one %what%', + ], + 'foobar' => [ + 'post.num_comments' => 'Il y a 1 comment|Il y a %count% comments', + ], + ]) + ); + + $this->assertFileExists(self::$translationsDumpDir.'/index.js'); + $this->assertFileExists(self::$translationsDumpDir.'/index.d.ts'); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JAVASCRIPT' +export const NOTIFICATION_COMMENT_CREATED = {"id":"notification.comment_created","translations":{"messages+intl-icu":{"en":"Your post received a comment!","fr":"Votre article a re\u00e7u un commentaire !"}}}; +export const NOTIFICATION_COMMENT_CREATED_DESCRIPTION = {"id":"notification.comment_created.description","translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant ce lien<\/a>"}}}; +export const POST_NUM_COMMENTS = {"id":"post.num_comments","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}}","fr":"{count, plural, one {# commentaire} other {# commentaires}}"},"foobar":{"en":"There is 1 comment|There are %count% comments","fr":"Il y a 1 comment|Il y a %count% comments"}}}; +export const SYMFONY_GREAT = {"id":"symfony.great","translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}}; +export const SYMFONY_WHAT = {"id":"symfony.what","translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}}; +export const APPLES_COUNT0 = {"id":"apples.count.0","translations":{"messages":{"en":"There is 1 apple|There are %count% apples","fr":"Il y a 1 pomme|Il y a %count% pommes"}}}; +export const APPLES_COUNT1 = {"id":"apples.count.1","translations":{"messages":{"en":"{1} There is one apple|]1,Inf] There are %count% apples","fr":"{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}}; +export const APPLES_COUNT2 = {"id":"apples.count.2","translations":{"messages":{"en":"{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples","fr":"{0} Il n'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}}; +export const APPLES_COUNT3 = {"id":"apples.count.3","translations":{"messages":{"en":"one: There is one apple|more: There are %count% apples","fr":"one: Il y a une pomme|more: Il y a %count% pommes"}}}; +export const APPLES_COUNT4 = {"id":"apples.count.4","translations":{"messages":{"en":"one: There is one apple|more: There are more than one apple","fr":"one: Il y a une pomme|more: Il y a plus d'une pomme"}}}; +export const WHAT_COUNT1 = {"id":"what.count.1","translations":{"messages":{"en":"{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}}; +export const WHAT_COUNT2 = {"id":"what.count.2","translations":{"messages":{"en":"{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{0} Il n'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}}; +export const WHAT_COUNT3 = {"id":"what.count.3","translations":{"messages":{"en":"one: There is one %what%|more: There are %count% %what%","fr":"one: Il y a une %what%|more: Il y a %count% %what%"}}}; +export const WHAT_COUNT4 = {"id":"what.count.4","translations":{"messages":{"en":"one: There is one %what%|more: There are more than one %what%","fr":"one: Il y a une %what%|more: Il y a more than one %what%"}}}; + +JAVASCRIPT); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TYPESCRIPT' +import { Message, NoParametersType } from '@symfony/ux-translator'; + +export declare const NOTIFICATION_COMMENT_CREATED: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'|'fr'>; +export declare const NOTIFICATION_COMMENT_CREATED_DESCRIPTION: Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>; +export declare const POST_NUM_COMMENTS: Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>; +export declare const SYMFONY_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; +export declare const SYMFONY_WHAT: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; +export declare const APPLES_COUNT0: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; +export declare const APPLES_COUNT1: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; +export declare const APPLES_COUNT2: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; +export declare const APPLES_COUNT3: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; +export declare const APPLES_COUNT4: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; +export declare const WHAT_COUNT1: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; +export declare const WHAT_COUNT2: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; +export declare const WHAT_COUNT3: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; +export declare const WHAT_COUNT4: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; + +TYPESCRIPT); + } +} diff --git a/src/Translator/tests/TranslatorBundleTest.php b/src/Translator/tests/TranslatorBundleTest.php new file mode 100644 index 00000000000..49ed3e65aeb --- /dev/null +++ b/src/Translator/tests/TranslatorBundleTest.php @@ -0,0 +1,26 @@ + [new EmptyAppKernel('test', true)]; + yield 'framework' => [new FrameworkAppKernel('test', true)]; + } + + /** + * @dataProvider provideKernels + */ + public function testBootKernel(Kernel $kernel) + { + $kernel->boot(); + $this->assertArrayHasKey('TranslatorBundle', $kernel->getBundles()); + } +}