diff --git a/src/Translator/assets/dist/translator.d.ts b/src/Translator/assets/dist/translator.d.ts index 70da6bb4cbf..62b37b44cb6 100644 --- a/src/Translator/assets/dist/translator.d.ts +++ b/src/Translator/assets/dist/translator.d.ts @@ -11,6 +11,7 @@ export type LocaleOf = M extends Message ? Lo export type ParametersOf = M extends Message ? Translations[D] extends { parameters: infer Parameters; } ? Parameters : never : never; +export type RegisteredTranslationsType = Record>>; export interface Message { id: string; translations: { @@ -24,3 +25,4 @@ 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; +export declare function registerDomain(domainTranslations: RegisteredTranslationsType): void; diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js index a92243af5f1..7ab6db82878 100644 --- a/src/Translator/assets/dist/translator_controller.js +++ b/src/Translator/assets/dist/translator_controller.js @@ -190,6 +190,7 @@ function getPluralizationRule(number, locale) { let _locale = null; let _localeFallbacks = {}; +const _registeredTranslations = {}; function setLocale(locale) { _locale = locale; } @@ -212,6 +213,9 @@ function trans(message, parameters = {}, domain = 'messages', locale = null) { if (typeof locale === 'undefined' || null === locale) { locale = getLocale(); } + if (typeof message === 'string') { + message = getRegisteredMessage(message, domain); + } if (typeof message.translations === 'undefined') { return message.id; } @@ -242,5 +246,27 @@ function trans(message, parameters = {}, domain = 'messages', locale = null) { } return message.id; } +function registerDomain(domainTranslations) { + for (const [domainName, translationsByLocale] of Object.entries(domainTranslations)) { + _registeredTranslations[domainName] = translationsByLocale; + } +} +function getRegisteredMessage(key, domain) { + var _a; + var _b; + const message = { id: key, translations: {} }; + for (const domainName of [domain, domain + '+intl-icu']) { + if (typeof _registeredTranslations[domainName] === 'undefined') { + continue; + } + for (const [locale, translations] of Object.entries(_registeredTranslations[domainName])) { + if (typeof translations[key] !== 'undefined') { + (_a = (_b = message.translations)[domainName]) !== null && _a !== void 0 ? _a : (_b[domainName] = {}); + message.translations[domainName][locale] = translations[key]; + } + } + } + return message; +} -export { getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, trans }; +export { getLocale, getLocaleFallbacks, registerDomain, setLocale, setLocaleFallbacks, trans }; diff --git a/src/Translator/assets/src/translator.ts b/src/Translator/assets/src/translator.ts index 8103ccadf9e..8ce1f7c2b99 100644 --- a/src/Translator/assets/src/translator.ts +++ b/src/Translator/assets/src/translator.ts @@ -25,6 +25,8 @@ export type ParametersOf = M extends Message>>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface Message { id: string; @@ -41,6 +43,8 @@ import { format } from './formatters/formatter'; let _locale: LocaleType | null = null; let _localeFallbacks: Record = {}; +const _registeredTranslations: RegisteredTranslationsType = {}; + export function setLocale(locale: LocaleType | null) { _locale = locale; } @@ -113,7 +117,7 @@ export function trans< : [message: M, parameters: P, domain?: RemoveIntlIcuSuffix, locale?: LocaleOf] ): string; export function trans< - M extends Message, + M extends Message | string, D extends DomainsOf, P extends ParametersOf >( @@ -130,6 +134,10 @@ export function trans< locale = getLocale() as LocaleOf; } + if (typeof message === 'string') { + message = getRegisteredMessage(message, domain); + } + if (typeof message.translations === 'undefined') { return message.id; } @@ -166,3 +174,28 @@ export function trans< return message.id; } + +export function registerDomain(domainTranslations: RegisteredTranslationsType) { + for (const [domainName, translationsByLocale] of Object.entries(domainTranslations)) { + _registeredTranslations[domainName] = translationsByLocale; + } +} + +function getRegisteredMessage(key: string, domain: string): Message { + const message: Message = { id: key, translations: {} }; + + for (const domainName of [domain, domain + '+intl-icu']) { + if (typeof _registeredTranslations[domainName] === 'undefined') { + continue; + } + + for (const [locale, translations] of Object.entries(_registeredTranslations[domainName])) { + if (typeof translations[key] !== 'undefined') { + message.translations[domainName] ??= {}; + message.translations[domainName][locale] = translations[key]; + } + } + } + + return message; +} diff --git a/src/Translator/assets/test/translator.test.ts b/src/Translator/assets/test/translator.test.ts index 6f4e05ed34b..c4f923d623e 100644 --- a/src/Translator/assets/test/translator.test.ts +++ b/src/Translator/assets/test/translator.test.ts @@ -1,9 +1,18 @@ -import {getLocale, Message, NoParametersType, setLocale, setLocaleFallbacks, trans} from '../src/translator'; +import { + getLocale, + Message, + NoParametersType, + setLocale, + setLocaleFallbacks, + trans, + registerDomain, + DomainMessages, +} from '../src/translator'; describe('Translator', function () { beforeEach(function() { setLocale(null); - setLocaleFallbacks({}) + setLocaleFallbacks({}); document.documentElement.lang = ''; document.documentElement.removeAttribute('data-symfony-ux-translator-locale'); }) @@ -60,6 +69,31 @@ describe('Translator', function () { expect(trans(MESSAGE_BASIC, {}, 'messages', 'fr')).toEqual('message.basic'); }); + test('basic message in registered domain', function () { + const MESSAGES_DOMAIN: DomainMessages = { + 'messages': { + 'en': { + 'message.basic': 'A basic message', + } + } + }; + registerDomain(MESSAGES_DOMAIN); + + 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', @@ -104,6 +138,50 @@ describe('Translator', function () { }, 'messages', 'fr')).toEqual('message.basic.with.parameters'); }); + test('basic message with parameters in registered domain', function () { + const MESSAGES_DOMAIN: DomainMessages = { + 'messages': { + 'en': { + 'message.basic.with.parameters': 'A basic message %parameter1% %parameter2%', + } + } + }; + registerDomain(MESSAGES_DOMAIN); + + 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', @@ -129,6 +207,31 @@ describe('Translator', function () { expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('message.intl'); }); + test('intl message in registered domain', function () { + const MESSAGES_INTL_DOMAIN: DomainMessages = { + 'messages+intl-icu': { + 'en': { + 'message.intl': 'An intl message', + } + } + }; + registerDomain(MESSAGES_INTL_DOMAIN); + + 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': { @@ -239,6 +342,107 @@ describe('Translator', function () { )).toEqual('message.intl.with.parameters'); }); + test('intl message with parameters in registered domain', function () { + const MESSAGES_INTL_DOMAIN: DomainMessages = { + 'messages+intl-icu': { + 'en': { + 'message.intl.with.parameters': ` +{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(), + } + } + }; + registerDomain(MESSAGES_INTL_DOMAIN); + + expect(trans('message.intl.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('message.intl.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('message.intl.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('message.intl.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('message.intl.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('message.intl.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('message.intl.with.parameters', { + gender_of_host: 'male', + num_guests: 123, + host: 'John', + }) + }).toThrow(/^The intl string context variable "guest" was not provided/); + + expect( + trans('message.intl.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('message.intl.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', @@ -270,6 +474,42 @@ describe('Translator', function () { expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar', 'fr')).toEqual('message.multi_domains'); }); + test('same message id for multiple domains in registered domains', function () { + const FOOBAR_DOMAIN: DomainMessages = { + 'foobar': { + 'en': { + 'message.multi_domains': 'A message from foobar catalogue' + } + } + }; + const MESSAGES_DOMAIN: DomainMessages = { + 'messages': { + 'en': { + 'message.multi_domains': 'A message from messages catalogue', + } + } + }; + registerDomain(FOOBAR_DOMAIN); + registerDomain(MESSAGES_DOMAIN); + + 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', @@ -299,6 +539,40 @@ describe('Translator', function () { expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages', 'fr')).toEqual('message.multi_domains.different_parameters'); }); + test('same message id for multiple domains, and different parameters in registered domains', function () { + const FOOBAR_DOMAIN: DomainMessages = { + 'foobar': { + 'en': { + 'message.multi_domains.different_parameters': 'A message from foobar catalogue with a parameter %parameter2%', + } + } + }; + const MESSAGES_DOMAIN: DomainMessages = { + 'messages': { + 'en': { + 'message.multi_domains.different_parameters': 'A message from messages catalogue with a parameter %parameter1%', + } + } + }; + registerDomain(FOOBAR_DOMAIN); + registerDomain(MESSAGES_DOMAIN); + + expect(trans('message.multi_domains.different_parameters', {'%parameter1%': 'foo'})).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans('message.multi_domains.different_parameters', {'%parameter1%': 'foo'}, 'messages')).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans('message.multi_domains.different_parameters', {'%parameter1%': 'foo'}, 'messages', 'en')).toEqual('A message from messages catalogue with a parameter foo'); + expect(trans('message.multi_domains.different_parameters', {'%parameter2%': 'foo'}, 'foobar')).toEqual('A message from foobar catalogue with a parameter foo'); + expect(trans('message.multi_domains.different_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.different_parameters', {})).toEqual('A message from messages catalogue with a parameter %parameter1%'); + + // @ts-expect-error Domain "baz" is invalid + expect(trans('message.multi_domains.different_parameters', {'%parameter1%': 'foo'}, 'baz')).toEqual('message.multi_domains.different_parameters'); + + // @ts-expect-error Locale "fr" is invalid + expect(trans('message.multi_domains.different_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', @@ -318,6 +592,27 @@ describe('Translator', function () { expect(trans(MESSAGE, {}, 'messages', 'en')).toEqual('A intl message'); }); + test('message from intl domain should be prioritized over its non-intl equivalent from domain', function () { + const MESSAGES_DOMAIN: DomainMessages = { + 'messages+intl-icu': { + 'en': { + 'message': 'A intl message' + } + }, + 'messages': { + 'en': { + 'message': 'A basic message' + } + } + } + registerDomain(MESSAGES_DOMAIN); + + 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'}); @@ -369,6 +664,55 @@ describe('Translator', function () { 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'); - }) + }); + + test('fallback behavior in registered domain', function() { + setLocaleFallbacks({'fr_FR':'fr','fr':'en','en_US':'en','en_GB':'en','de_DE':'de','de':'en'}); + const MESSAGES_DOMAIN: DomainMessages = { + 'messages': { + 'en': { + 'message': 'A message in english' + }, + 'en_US': { + 'message': 'A message in english (US)' + }, + 'fr': { + 'message': 'Un message en français', + 'message_french_only': 'Un message en français uniquement', + } + }, + 'messages+intl-icu': { + 'en': { + 'message_intl': 'A intl message in english' + }, + 'en_US': { + 'message_intl': 'A intl message in english (US)' + }, + 'fr': { + 'message_intl': 'Un message intl en français', + } + } + } + + registerDomain(MESSAGES_DOMAIN); + + expect(trans('message', {}, 'messages', 'en')).toEqual('A message in english'); + expect(trans('message_intl', {}, 'messages+intl-icu', 'en')).toEqual('A intl message in english'); + expect(trans('message', {}, 'messages', 'en_US')).toEqual('A message in english (US)'); + expect(trans('message_intl', {}, 'messages+intl-icu', '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+intl-icu', '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+intl-icu', '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+intl-icu', '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+intl-icu', '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/config/services.php b/src/Translator/config/services.php index e5eb6bab50b..24a868a1c1a 100644 --- a/src/Translator/config/services.php +++ b/src/Translator/config/services.php @@ -12,6 +12,9 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer; +use Symfony\UX\Translator\Dumper\Front\DomainModuleDumper; +use Symfony\UX\Translator\Dumper\Front\TranslationConfigDumper; +use Symfony\UX\Translator\Dumper\Front\MessageConstantDumper; use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor; use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; @@ -31,12 +34,26 @@ ->set('ux.translator.translations_dumper', TranslationsDumper::class) ->args([ - null, // Dump directory + abstract_arg('Dump directory'), + ]) + ->set('ux.translator.translations_dumper.message_constant', MessageConstantDumper::class) + ->args([ 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'), ]) + ->tag('ux.translator.front_translations_dumper') + ->set('ux.translator.translations_dumper.domain_module', DomainModuleDumper::class) + ->args([ + service('filesystem'), + ]) + ->tag('ux.translator.front_translations_dumper') + ->set('ux.translator.translations_dumper.configuration', TranslationConfigDumper::class) + ->args([ + service('filesystem'), + ]) + ->tag('ux.translator.front_translations_dumper') ->set('ux.translator.message_parameters.extractor.message_parameters_extractor', MessageParametersExtractor::class) diff --git a/src/Translator/doc/index.rst b/src/Translator/doc/index.rst index 4872d569f6d..ce5cc2bd9c1 100644 --- a/src/Translator/doc/index.rst +++ b/src/Translator/doc/index.rst @@ -128,22 +128,37 @@ Using with AssetMapper Using this library with AssetMapper is possible, but is currently experimental and may not be ready yet for production. -When installing with AssetMapper, Flex will add a few new items to your ``importmap.php`` -file. 2 of the new items are:: - - '@app/translations' => [ - 'path' => 'var/translations/index.js', - ], - '@app/translations/configuration' => [ - 'path' => 'var/translations/configuration.js', - ], - -These are then imported in your ``assets/translator.js`` file. This setup is -very similar to working with WebpackEncore. However, the ``var/translations/index.js`` -file contains *every* translation in your app, which is not ideal for production -and may even leak translations only meant for admin areas. Encore solves this via -tree-shaking, but the AssetMapper component does not. There is not, yet, a way to -solve this properly with the AssetMapper component. +First, you need to define that you want to use the translator with AssetMapper in +``config/packages/ux_translator.yaml``: + +.. code-block:: yaml + + symfony_ux_translator: + asset_mapper_mode: true + +This will instruct the bundle to dump the translations by domain as JavaScript modules. In your JavaScript files, +register the domains that you need using the ``registerDomain`` function. To translate your messages, use the ``trans`` +function just like you would with the WebpackEncore version, but by passing the message key as the first argument. + +.. code-block:: javascript + + // assets/my_file.js + + import { registerDomain, trans } from './translator.js'; + import MESSAGES from '../var/translations/domains/messages.js'; + import OTHER_DOMAIN from '../var/translations/domains/other_domain.js'; + + // Register the domains that you need + registerDomain(MESSAGES); + registerDomain(OTHER_DOMAIN); + + // Use the trans function to translate your messages + trans('custom_message_key'); + trans('other_message_key', { count: 123, foo: 'bar' }, 'other_domain', 'fr'); + +.. note:: + + If some domains are common to all pages, you can register them in ``assets/translator.js`` to prevent duplication. Backward Compatibility promise ------------------------------ diff --git a/src/Translator/src/DependencyInjection/Configuration.php b/src/Translator/src/DependencyInjection/Configuration.php index 485c1bdcb2a..acc6cfd5539 100644 --- a/src/Translator/src/DependencyInjection/Configuration.php +++ b/src/Translator/src/DependencyInjection/Configuration.php @@ -28,6 +28,13 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode ->children() ->scalarNode('dump_directory')->defaultValue('%kernel.project_dir%/var/translations')->end() + ->booleanNode('asset_mapper_mode') + ->info(<<<'EOF' + If set to 'true', translations will be dumped as separated modules for each domain. + This allows loading only the desired domains when using AssetMapper. + EOF) + ->defaultValue(false) + ->end() ->end() ; diff --git a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php index 65a154840ed..ee24fbd569e 100644 --- a/src/Translator/src/DependencyInjection/UxTranslatorExtension.php +++ b/src/Translator/src/DependencyInjection/UxTranslatorExtension.php @@ -17,6 +17,7 @@ use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\Translator\Dumper\Front\FrontFileDumperInterface; /** * @author Hugo Alliaume @@ -35,7 +36,21 @@ public function load(array $configs, ContainerBuilder $container) $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); $loader->load('services.php'); - $container->getDefinition('ux.translator.translations_dumper')->setArgument(0, $config['dump_directory']); + $dumpDir = $config['dump_directory']; + $assetMapperMode = $config['asset_mapper_mode']; + + $translationDumper = $container->getDefinition('ux.translator.translations_dumper'); + $translationDumper->setArgument(0, $dumpDir); + if ($assetMapperMode) { + $translationDumper->addMethodCall('addDumper', [$container->getDefinition('ux.translator.translations_dumper.domain_module')]); + } + + $translationDumper->addMethodCall('addDumper', [$container->getDefinition('ux.translator.translations_dumper.message_constant')]); + $translationDumper->addMethodCall('addDumper', [$container->getDefinition('ux.translator.translations_dumper.configuration')]); + + foreach ($container->findTaggedServiceIds('ux.translator.front_translations_dumper') as $id => $attributes) { + $container->getDefinition($id)->addMethodCall('setDumpDir', [$dumpDir]); + } } public function prepend(ContainerBuilder $container) @@ -44,11 +59,13 @@ public function prepend(ContainerBuilder $container) return; } + $config = $this->processConfiguration(new Configuration(), $container->getExtensionConfig($this->getAlias())); + $container->prependExtensionConfig('framework', [ 'asset_mapper' => [ 'paths' => [ __DIR__.'/../../assets/dist' => '@symfony/ux-translator', - '%kernel.project_dir%/var/translations' => 'var/translations', + $config['dump_directory'] => 'var/translations', ], ], ]); diff --git a/src/Translator/src/Dumper/Front/AbstractFrontFileDumper.php b/src/Translator/src/Dumper/Front/AbstractFrontFileDumper.php new file mode 100644 index 00000000000..bb9308c6407 --- /dev/null +++ b/src/Translator/src/Dumper/Front/AbstractFrontFileDumper.php @@ -0,0 +1,15 @@ +dumpDir = $dumpDir; + + return $this; + } +} diff --git a/src/Translator/src/Dumper/Front/DomainModuleDumper.php b/src/Translator/src/Dumper/Front/DomainModuleDumper.php new file mode 100644 index 00000000000..5244e2bc5e0 --- /dev/null +++ b/src/Translator/src/Dumper/Front/DomainModuleDumper.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Dumper\Front; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Translation\Dumper\JsonFileDumper; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\UX\Translator\Dumper\ModuleDumper; + +use function Symfony\Component\String\s; + +/** + * @final + * + * @experimental + * + * A dumper that generates a JavaScript module file for each domain/locale + a main module + * per domain to easily import all it's translations at once. + */ +class DomainModuleDumper extends AbstractFrontFileDumper implements FrontFileDumperInterface +{ + /** + * Pattern used to extract full domain name and locale from a file dumped by the ModuleDumper. + * @see https://regex101.com/r/pMxiOm/1 + */ + private const TRANSLATIONS_FILENAME_PATTERN = '#\./translations/([a-zA-Z0-9\-+_]+)\.([a-zA-Z_]+)\.js$#'; + + public function __construct( + private Filesystem $filesystem, + ) { + } + + public function dump(MessageCatalogueInterface ...$catalogues): void + { + $this->filesystem->mkdir($this->dumpDir); + $this->filesystem->remove($this->dumpDir.'/domains'); + $this->filesystem->mkdir($this->dumpDir.'/domains'); + $this->filesystem->mkdir($this->dumpDir.'/domains/translations'); + + $fileDumper = new ModuleDumper(new JsonFileDumper()); + $domains = []; + + // Generate a module file for each domain and locale + foreach ($catalogues as $catalogue) { + $fileDumper->dump($catalogue, ['path' => $this->dumpDir.'/domains/translations', 'json_encoding' => \JSON_PRETTY_PRINT]); + $domains = array_merge($domains, $catalogue->getDomains()); + } + + // Generate a module file for each domain, that exposes translations indexed by domain, then locale + foreach (array_unique($domains) as $domain) { + $this->dumpDomainModule($domain); + } + } + + private static function getModuleTemplate(): string + { + return <<<'JAVASCRIPT' +%s + +export default { +%s +}; +JAVASCRIPT; + } + + /** + * From a given domain name, looks for all previously dumped translations files that matches this domain (by locale + intl-icu). + * Then, generates a module file that imports all these files, and exports an object with translations indexed + * by domain (simple +intl-icu) then by locale. + * Ex: + * import messages_en from './translations/messages.en.js'; + * import messages_fr from './translations/messages.fr.js'; + * import messages_intl_icu_en from './translations/messages+intl-icu.en.js'; + * import messages_intl_icu_fr from './translations/messages+intl-icu.fr.js'; + * export default { + * 'messages': { + * 'en': messages_en, + * 'fr': messages_fr, + * }, + * 'messages+intl-icu': { + * 'en': messages_intl_icu_en, + * 'fr': messages_intl_icu_fr, + * }, + * } + */ + private function dumpDomainModule(string $domain): void + { + $relativeDomainFiles = $this->getTranslationsRelativePathsForDomain($domain); + $importString = ''; + $translationsByDomain = []; + foreach ($relativeDomainFiles as $file) { + $matches = []; + preg_match(self::TRANSLATIONS_FILENAME_PATTERN, $file, $matches); + [,$fullDomain,$locale] = $matches; + + $variableName = s($fullDomain)->ascii()->snake()->append('_'.$locale)->toString(); + $importString .= \sprintf("import %s from '%s';\n", $variableName, $file); + + $translationsByDomain[$fullDomain] ??= ''; + $translationsByDomain[$fullDomain] .= \sprintf(" '%s': %s,\n", $locale, $variableName); + } + + $dataString = ''; + foreach ($translationsByDomain as $fullDomain => $line) { + $dataString .= \sprintf(" '%s': {\n%s },\n", $fullDomain, $line); + } + + $importString = rtrim($importString, "\n"); + $dataString = rtrim($dataString, "\n"); + + $this->filesystem->dumpFile( + $this->dumpDir.'/domains/'.$domain.'.js', + \sprintf($this->getModuleTemplate(), $importString, $dataString) + ); + } + + /** + * @return list List of relative paths to translation files for a given domain + */ + private function getTranslationsRelativePathsForDomain(string $domain): array + { + $translationFiles = glob( + $this->dumpDir.'/domains/translations/'.$domain.'{,+intl-icu}.*', + \GLOB_NOSORT | \GLOB_BRACE + ); + + return array_map(fn ($path) => rtrim('./'.$this->filesystem->makePathRelative($path, $this->dumpDir.'/domains'), '/'), $translationFiles); + } +} diff --git a/src/Translator/src/Dumper/Front/FrontFileDumperInterface.php b/src/Translator/src/Dumper/Front/FrontFileDumperInterface.php new file mode 100644 index 00000000000..be11f78a530 --- /dev/null +++ b/src/Translator/src/Dumper/Front/FrontFileDumperInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Dumper\Front; + +use Symfony\Component\Translation\MessageCatalogueInterface; + +interface FrontFileDumperInterface +{ + public function dump(MessageCatalogueInterface ...$catalogues): void; + public function setDumpDir(string $dumpDir): static; +} diff --git a/src/Translator/src/Dumper/Front/MessageConstantDumper.php b/src/Translator/src/Dumper/Front/MessageConstantDumper.php new file mode 100644 index 00000000000..4b86f437a5e --- /dev/null +++ b/src/Translator/src/Dumper/Front/MessageConstantDumper.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Dumper\Front; + +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 MessageConstantDumper extends AbstractFrontFileDumper implements FrontFileDumperInterface +{ + + public function __construct( + 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'); + + $translationsJs = ''; + $translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n"; + + foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) { + $constantName = $this->generateConstantName($translationId); + + $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); + } + + /** + * @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 generateConstantName(string $translationId): string + { + static $alreadyGenerated = []; + + $prefix = 0; + do { + $constantName = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString().($prefix > 0 ? '_'.$prefix : ''); + ++$prefix; + } while (\in_array($constantName, $alreadyGenerated, true)); + + $alreadyGenerated[] = $constantName; + + return $constantName; + } +} diff --git a/src/Translator/src/Dumper/Front/TranslationConfigDumper.php b/src/Translator/src/Dumper/Front/TranslationConfigDumper.php new file mode 100644 index 00000000000..2a11e348621 --- /dev/null +++ b/src/Translator/src/Dumper/Front/TranslationConfigDumper.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\Dumper\Front; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Translation\MessageCatalogueInterface; + +/** + * @author Hugo Alliaume + * + * @final + * + * @experimental + * + * @phpstan-type Domain string + * @phpstan-type Locale string + * @phpstan-type MessageId string + */ +class TranslationConfigDumper extends AbstractFrontFileDumper implements FrontFileDumperInterface +{ + public function __construct( + private Filesystem $filesystem, + ) { + } + + public function dump(MessageCatalogueInterface ...$catalogues): void + { + $this->filesystem->mkdir($this->dumpDir); + $this->filesystem->remove($this->dumpDir.'/configuration.js'); + $this->filesystem->remove($this->dumpDir.'/configuration.d.ts'); + + $this->filesystem->dumpFile($this->dumpDir.'/configuration.js', + 'export const localeFallbacks = '. + json_encode($this->getLocaleFallbacks(...$catalogues), \JSON_THROW_ON_ERROR). + ";\n", + ); + $this->filesystem->dumpFile($this->dumpDir.'/configuration.d.ts', <<<'TS' +import { LocaleType } from '@symfony/ux-translator'; + +export declare const localeFallbacks: Record; +TS + ); + } + + 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/Dumper/ModuleDumper.php b/src/Translator/src/Dumper/ModuleDumper.php new file mode 100644 index 00000000000..bddd569023a --- /dev/null +++ b/src/Translator/src/Dumper/ModuleDumper.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Dumper; + +use Symfony\Component\Translation\Dumper\FileDumper; +use Symfony\Component\Translation\MessageCatalogue; + +/** + * @author Maelan Le Borgne + * + * @final + * + * @experimental + * + * A dumper that generates JavaScript module files. + */ +class ModuleDumper extends FileDumper +{ + public function __construct( + private FileDumper $inner, + ) { + } + + public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string + { + return 'export default '.$this->inner->formatCatalogue($messages, $domain, $options).';'; + } + + protected function getExtension(): string + { + return 'js'; + } +} diff --git a/src/Translator/src/TranslationsDumper.php b/src/Translator/src/TranslationsDumper.php index 751f4203da0..596f55489d0 100644 --- a/src/Translator/src/TranslationsDumper.php +++ b/src/Translator/src/TranslationsDumper.php @@ -13,12 +13,12 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\UX\Translator\Dumper\Front\AbstractFrontFileDumper; +use Symfony\UX\Translator\Dumper\Front\FrontFileDumperInterface; 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 * @@ -30,148 +30,32 @@ * @phpstan-type Locale string * @phpstan-type MessageId string */ -class TranslationsDumper +class TranslationsDumper extends AbstractFrontFileDumper { + private array $dumpers = []; + public function __construct( - private string $dumpDir, - private MessageParametersExtractor $messageParametersExtractor, - private IntlMessageParametersExtractor $intlMessageParametersExtractor, - private TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter, - private Filesystem $filesystem, + string $dumpDir, + private ?MessageParametersExtractor $messageParametersExtractor = null, + private ?IntlMessageParametersExtractor $intlMessageParametersExtractor = null, + private ?TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter = null, + private ?Filesystem $filesystem = null, ) { - } - - 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 = $this->generateConstantName($translationId); - - $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; - } - } + $this->setDumpDir($dumpDir); + if (isset($messageParametersExtractor, $intlMessageParametersExtractor, $typeScriptMessageParametersPrinter, $filesystem)) { + trigger_deprecation('symfony/ux-translator', '2.19', 'The "%s" class will not require the "%s", "%s", "%s" and "%s" arguments in version 3.0.', __CLASS__, MessageParametersExtractor::class, IntlMessageParametersExtractor::class, TypeScriptMessageParametersPrinter::class, Filesystem::class); } - - return $translations; } - /** - * @param array> $translationsByDomainAndLocale - * - * @throws \Exception - */ - private function getTranslationsTypeScriptTypeDefinition(array $translationsByDomainAndLocale): string + public function addDumper(FrontFileDumperInterface $dumper): void { - $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))), - ); + $this->dumpers[] = $dumper; } - private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): array + public function dump(MessageCatalogueInterface ...$catalogues): void { - $localesFallbacks = []; - - foreach ($catalogues as $catalogue) { - $localesFallbacks[$catalogue->getLocale()] = $catalogue->getFallbackCatalogue()?->getLocale(); + foreach ($this->dumpers as $dumper) { + $dumper->dump(...$catalogues); } - - return $localesFallbacks; - } - - private function generateConstantName(string $translationId): string - { - static $alreadyGenerated = []; - - $prefix = 0; - do { - $constantName = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString().($prefix > 0 ? '_'.$prefix : ''); - ++$prefix; - } while (\in_array($constantName, $alreadyGenerated, true)); - - $alreadyGenerated[] = $constantName; - - return $constantName; } } diff --git a/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php b/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php index d97e49e0967..c422f5545a6 100644 --- a/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php +++ b/src/Translator/tests/CacheWarmer/TranslationsCacheWarmerTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\TranslatorBag; use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer; +use Symfony\UX\Translator\Dumper\Front\FrontFileDumperInterface; use Symfony\UX\Translator\TranslationsDumper; final class TranslationsCacheWarmerTest extends TestCase @@ -41,16 +42,17 @@ public function test() ], ]) ); - - $translationsDumperMock = $this->createMock(TranslationsDumper::class); - $translationsDumperMock - ->expects($this->once()) + $wrappedTranslationsDumper = $this->createMock(FrontFileDumperInterface::class); + $wrappedTranslationsDumper->expects($this->once()) ->method('dump') ->with(...$translatorBag->getCatalogues()); + $translationsDumper = new TranslationsDumper('dumpDir'); + $translationsDumper->addDumper($wrappedTranslationsDumper); + $translationsCacheWarmer = new TranslationsCacheWarmer( $translatorBag, - $translationsDumperMock + $translationsDumper, ); $translationsCacheWarmer->warmUp(self::$cacheDir); diff --git a/src/Translator/tests/Dumper/Front/DomainModuleDumperTest.php b/src/Translator/tests/Dumper/Front/DomainModuleDumperTest.php new file mode 100644 index 00000000000..787f09340c7 --- /dev/null +++ b/src/Translator/tests/Dumper/Front/DomainModuleDumperTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests\Dumper\Front; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\UX\Translator\Dumper\Front\DomainModuleDumper; + +/** + * @uses \Symfony\UX\Translator\Dumper\ModuleDumper + */ +class DomainModuleDumperTest extends TestCase +{ + protected static $translationsDumpDir; + protected static $fixturesDir = __DIR__.'/../../fixtures'; + private DomainModuleDumper $translationsDumper; + + public static function setUpBeforeClass(): void + { + self::$translationsDumpDir = sys_get_temp_dir().'/sf_ux_translator/'.uniqid('translations', true); + } + + public static function tearDownAfterClass(): void + { + @rmdir(self::$translationsDumpDir); + } + + protected function setUp(): void + { + $this->translationsDumper = new DomainModuleDumper( + new Filesystem(), + ); + $this->translationsDumper->setDumpDir(self::$translationsDumpDir); + } + + public function testDump(): void + { + $this->translationsDumper->dump(...$this->getMessageCatalogues()); + + $this->assertFileExists(self::$translationsDumpDir.'/domains/messages.js'); + $this->assertFileExists(self::$translationsDumpDir.'/domains/foobar.js'); + + $this->assertStringContainsString(<<<'JS' +import messages_fr from './translations/messages.fr.js'; +import messages_en from './translations/messages.en.js'; +import messages_intl_icu_fr from './translations/messages+intl-icu.fr.js'; +import messages_intl_icu_en from './translations/messages+intl-icu.en.js'; + +export default { + 'messages': { + 'fr': messages_fr, + 'en': messages_en, + }, + 'messages+intl-icu': { + 'fr': messages_intl_icu_fr, + 'en': messages_intl_icu_en, + }, +}; +JS, file_get_contents(self::$translationsDumpDir.'/domains/messages.js')); + + $this->assertStringContainsString(<<<'JS' + import foobar_en from './translations/foobar.en.js'; + import foobar_fr from './translations/foobar.fr.js'; + + export default { + 'foobar': { + 'en': foobar_en, + 'fr': foobar_fr, + }, + }; + JS, file_get_contents(self::$translationsDumpDir.'/domains/foobar.js')); + } + + /** + * @return list + */ + private function getMessageCatalogues(): array + { + return [ + new MessageCatalogue('en', include self::$fixturesDir.'/catalogue_en.php'), + new MessageCatalogue('fr', include self::$fixturesDir.'/catalogue_fr.php'), + ]; + } +} diff --git a/src/Translator/tests/TranslationsDumperTest.php b/src/Translator/tests/Dumper/Front/MessageConstantDumperTest.php similarity index 63% rename from src/Translator/tests/TranslationsDumperTest.php rename to src/Translator/tests/Dumper/Front/MessageConstantDumperTest.php index c21c3a8ccd5..1830846e776 100644 --- a/src/Translator/tests/TranslationsDumperTest.php +++ b/src/Translator/tests/Dumper/Front/MessageConstantDumperTest.php @@ -9,19 +9,20 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Translator\Tests; +namespace Symfony\UX\Translator\Tests\Dumper\Front; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Translation\MessageCatalogue; +use Symfony\UX\Translator\Dumper\Front\MessageConstantDumper; 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; -class TranslationsDumperTest extends TestCase +class MessageConstantDumperTest extends TestCase { protected static $translationsDumpDir; + protected static $fixturesDir = __DIR__.'/../../fixtures'; public static function setUpBeforeClass(): void { @@ -35,73 +36,17 @@ public static function tearDownAfterClass(): void public function testDump(): void { - $translationsDumper = new TranslationsDumper( - self::$translationsDumpDir, + $translationsDumper = new MessageConstantDumper( new MessageParametersExtractor(), new IntlMessageParametersExtractor(), new TypeScriptMessageParametersPrinter(), new Filesystem(), ); + $translationsDumper->setDumpDir(self::$translationsDumpDir); $translationsDumper->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}}', - 'post.num_comments.' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)', - ], - 'messages' => [ - 'symfony.great' => 'Symfony is awesome!', - 'symfony.what' => 'Symfony is %what%!', - 'symfony.what!' => 'Symfony is %what%! (should not conflict with the previous one.)', - 'symfony.what.' => 'Symfony is %what%. (should also not conflict with the previous one.)', - '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%', - 'animal.dog-cat' => 'Dog and cat', - 'animal.dog_cat' => 'Dog and cat (should not conflict with the previous one)', - '0starts.with.numeric' => 'Key starts with numeric char', - ], - '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}}', - 'post.num_comments.' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction précédente)', - ], - 'messages' => [ - 'symfony.great' => 'Symfony est génial !', - 'symfony.what' => 'Symfony est %what%!', - 'symfony.what!' => 'Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction précédente)', - 'symfony.what.' => 'Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction précédente)', - '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%', - 'animal.dog-cat' => 'Chien et chat', - 'animal.dog_cat' => 'Chien et chat (ne doit pas rentrer en conflit avec la traduction précédente)', - '0starts.with.numeric' => 'La touche commence par un caractère numérique', - ], - 'foobar' => [ - 'post.num_comments' => 'Il y a 1 comment|Il y a %count% comments', - ], - ]) + new MessageCatalogue('en', include self::$fixturesDir.'/catalogue_en.php'), + new MessageCatalogue('fr', include self::$fixturesDir.'/catalogue_fr.php') ); $this->assertFileExists(self::$translationsDumpDir.'/index.js'); diff --git a/src/Translator/tests/Dumper/Front/TranslationConfigDumperTest.php b/src/Translator/tests/Dumper/Front/TranslationConfigDumperTest.php new file mode 100644 index 00000000000..759372dabe8 --- /dev/null +++ b/src/Translator/tests/Dumper/Front/TranslationConfigDumperTest.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\Tests\Dumper\Front; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\UX\Translator\Dumper\Front\TranslationConfigDumper; + +class TranslationConfigDumperTest extends TestCase +{ + protected static $translationsDumpDir; + protected static $fixturesDir = __DIR__.'/../../fixtures'; + + public static function setUpBeforeClass(): void + { + self::$translationsDumpDir = sys_get_temp_dir().'/sf_ux_translator/'.uniqid('translations', true); + } + + public static function tearDownAfterClass(): void + { + @rmdir(self::$translationsDumpDir); + } + + public function testDumpNoFallback(): void + { + $translationsDumper = new TranslationConfigDumper( + new Filesystem(), + ); + $translationsDumper->setDumpDir(self::$translationsDumpDir); + + $translationsDumper->dump( + new MessageCatalogue('en', include self::$fixturesDir.'/catalogue_en.php'), + new MessageCatalogue('fr', include self::$fixturesDir.'/catalogue_fr.php') + ); + + $this->assertFileExists(self::$translationsDumpDir.'/configuration.js'); + $this->assertFileExists(self::$translationsDumpDir.'/configuration.d.ts'); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/configuration.js', <<<'JAVASCRIPT' +export const localeFallbacks = {"en":null,"fr":null}; + +JAVASCRIPT); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/configuration.d.ts', <<<'TYPESCRIPT' +import { LocaleType } from '@symfony/ux-translator'; + +export declare const localeFallbacks: Record; +TYPESCRIPT); + } + + public function testDumpWithFallback(): void + { + $translationsDumper = new TranslationConfigDumper( + new Filesystem(), + ); + $translationsDumper->setDumpDir(self::$translationsDumpDir); + + $enCatalogue = new MessageCatalogue('en', include self::$fixturesDir.'/catalogue_en.php'); + $frCatalogue = new MessageCatalogue('fr', include self::$fixturesDir.'/catalogue_fr.php'); + $frCatalogue->addFallbackCatalogue($enCatalogue); + $translationsDumper->dump($enCatalogue, $frCatalogue); + + $this->assertFileExists(self::$translationsDumpDir.'/configuration.js'); + $this->assertFileExists(self::$translationsDumpDir.'/configuration.d.ts'); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/configuration.js', <<<'JAVASCRIPT' +export const localeFallbacks = {"en":null,"fr":"en"}; + +JAVASCRIPT); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/configuration.d.ts', <<<'TYPESCRIPT' +import { LocaleType } from '@symfony/ux-translator'; + +export declare const localeFallbacks: Record; +TYPESCRIPT); + } +} diff --git a/src/Translator/tests/Dumper/ModuleDumperTest.php b/src/Translator/tests/Dumper/ModuleDumperTest.php new file mode 100644 index 00000000000..fd43bc62d19 --- /dev/null +++ b/src/Translator/tests/Dumper/ModuleDumperTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests\Dumper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Dumper\JsonFileDumper; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\UX\Translator\Dumper\ModuleDumper; + +class ModuleDumperTest extends TestCase +{ + protected static string $translationsDumpDir; + protected static string $fixturesDir = __DIR__.'/../fixtures'; + private ModuleDumper $translationsDumper; + + public static function setUpBeforeClass(): void + { + self::$translationsDumpDir = sys_get_temp_dir().'/sf_ux_translator/'.uniqid('translations', true); + } + + public static function tearDownAfterClass(): void + { + @rmdir(self::$translationsDumpDir); + } + + protected function setUp(): void + { + $this->translationsDumper = new ModuleDumper(new JsonFileDumper()); + } + + public function testDump(): void + { + $this->translationsDumper->dump( + new MessageCatalogue('en', include self::$fixturesDir.'/catalogue_en.php'), + ['path' => self::$translationsDumpDir.'/domains/translations'] + ); + + $this->assertFileExists(self::$translationsDumpDir.'/domains/translations/messages.en.js'); + $this->assertFileExists(self::$translationsDumpDir.'/domains/translations/messages+intl-icu.en.js'); + $this->assertFileExists(self::$translationsDumpDir.'/domains/translations/foobar.en.js'); + + $this->assertStringContainsString(<<<'JS' +export default { + "symfony.great": "Symfony is awesome!", + "symfony.what": "Symfony is %what%!", + "symfony.what!": "Symfony is %what%! (should not conflict with the previous one.)", + "symfony.what.": "Symfony is %what%. (should also not conflict with the previous one.)", + "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%", + "animal.dog-cat": "Dog and cat", + "animal.dog_cat": "Dog and cat (should not conflict with the previous one)", + "0starts.with.numeric": "Key starts with numeric char" +}; +JS, file_get_contents(self::$translationsDumpDir.'/domains/translations/messages.en.js')); + + + $this->assertStringContainsString(<<<'JS' +export default { + "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<\/a>", + "post.num_comments": "{count, plural, one {# comment} other {# comments}}", + "post.num_comments.": "{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)" +}; +JS, file_get_contents(self::$translationsDumpDir.'/domains/translations/messages+intl-icu.en.js')); + + $this->assertStringContainsString(<<<'JS' +export default { + "post.num_comments": "There is 1 comment|There are %count% comments" +}; +JS, file_get_contents(self::$translationsDumpDir.'/domains/translations/foobar.en.js')); + } +} diff --git a/src/Translator/tests/TranslationDumperTest.php b/src/Translator/tests/TranslationDumperTest.php new file mode 100644 index 00000000000..16f940486b0 --- /dev/null +++ b/src/Translator/tests/TranslationDumperTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Translator\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\UX\Translator\Dumper\Front\DomainModuleDumper; +use Symfony\UX\Translator\Dumper\Front\TranslationConfigDumper; +use Symfony\UX\Translator\Dumper\Front\MessageConstantDumper; +use Symfony\UX\Translator\TranslationsDumper; + +final class TranslationDumperTest extends TestCase +{ + protected static $cacheDir; + + public static function setUpBeforeClass(): void + { + self::$cacheDir = tempnam(sys_get_temp_dir(), 'sf_cache_warmer_dir'); + } + + public static function tearDownAfterClass(): void + { + @unlink(self::$cacheDir); + } + + public function test() + { + $translatorBag = new TranslatorBag(); + $messageObjectTranslationDumper = $this->createMock(MessageConstantDumper::class); + $messageObjectTranslationDumper + ->expects($this->once()) + ->method('dump') + ->with(...$translatorBag->getCatalogues()); + $domainModuleTranslationDumper = $this->createMock(DomainModuleDumper::class); + $domainModuleTranslationDumper + ->expects($this->once()) + ->method('dump') + ->with(...$translatorBag->getCatalogues()); + $frontConfigDumper = $this->createMock(TranslationConfigDumper::class); + $frontConfigDumper + ->expects($this->once()) + ->method('dump') + ->with(...$translatorBag->getCatalogues()); + + $mainTranslationDumper = new TranslationsDumper('dump_dir_path'); + $mainTranslationDumper->addDumper($messageObjectTranslationDumper); + $mainTranslationDumper->addDumper($domainModuleTranslationDumper); + $mainTranslationDumper->addDumper($frontConfigDumper); + + + $mainTranslationDumper->dump(...$translatorBag->getCatalogues()); + } +} diff --git a/src/Translator/tests/fixtures/catalogue_en.php b/src/Translator/tests/fixtures/catalogue_en.php new file mode 100644 index 00000000000..c50323fab3b --- /dev/null +++ b/src/Translator/tests/fixtures/catalogue_en.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + '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}}', + 'post.num_comments.' => '{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)', + ], + 'messages' => [ + 'symfony.great' => 'Symfony is awesome!', + 'symfony.what' => 'Symfony is %what%!', + 'symfony.what!' => 'Symfony is %what%! (should not conflict with the previous one.)', + 'symfony.what.' => 'Symfony is %what%. (should also not conflict with the previous one.)', + '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%', + 'animal.dog-cat' => 'Dog and cat', + 'animal.dog_cat' => 'Dog and cat (should not conflict with the previous one)', + '0starts.with.numeric' => 'Key starts with numeric char', + ], + 'foobar' => [ + 'post.num_comments' => 'There is 1 comment|There are %count% comments', + ], +]; diff --git a/src/Translator/tests/fixtures/catalogue_fr.php b/src/Translator/tests/fixtures/catalogue_fr.php new file mode 100644 index 00000000000..1709afda718 --- /dev/null +++ b/src/Translator/tests/fixtures/catalogue_fr.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + '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}}', + 'post.num_comments.' => '{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction précédente)', + ], + 'messages' => [ + 'symfony.great' => 'Symfony est génial !', + 'symfony.what' => 'Symfony est %what%!', + 'symfony.what!' => 'Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction précédente)', + 'symfony.what.' => 'Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction précédente)', + '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%', + 'animal.dog-cat' => 'Chien et chat', + 'animal.dog_cat' => 'Chien et chat (ne doit pas rentrer en conflit avec la traduction précédente)', + '0starts.with.numeric' => 'La touche commence par un caractère numérique', + ], + 'foobar' => [ + 'post.num_comments' => 'Il y a 1 comment|Il y a %count% comments', + ], +];