From 2519033bd08d1e96701a239aaaa686510f4f2982 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Wed, 3 Dec 2025 21:12:34 +0100 Subject: [PATCH 1/4] [Translator] Refactor API to use string-based translation keys instead of generated constants --- src/Translator/CHANGELOG.md | 49 ++ .../assets/dist/translator_controller.d.ts | 40 +- .../assets/dist/translator_controller.js | 119 ++-- src/Translator/assets/package.json | 4 +- .../assets/src/translator_controller.ts | 278 ++++----- src/Translator/assets/src/types.d.ts | 26 + .../test/unit/translator_controller.test.ts | 567 +++++++++++------- src/Translator/doc/index.rst | 138 ++--- src/Translator/src/TranslationsDumper.php | 91 ++- .../Functional/DumpEnabledLocalesTest.php | 36 +- .../tests/TranslationsDumperTest.php | 111 ++-- src/Translator/tests/bootstrap.php | 3 +- 12 files changed, 833 insertions(+), 629 deletions(-) create mode 100644 src/Translator/assets/src/types.d.ts diff --git a/src/Translator/CHANGELOG.md b/src/Translator/CHANGELOG.md index e1aba542b27..6cfe1c52b58 100644 --- a/src/Translator/CHANGELOG.md +++ b/src/Translator/CHANGELOG.md @@ -1,5 +1,54 @@ # CHANGELOG +## 2.32 + +- **[BC BREAK]** Refactor API to use string-based translation keys instead of generated constants. + + Translation keys are now simple strings instead of TypeScript constants. + The main advantages are: + - You can now use **exactly the same translation keys** as in your Symfony PHP code + - Simpler and more readable code + - No need to memorize generated constant names + - No need to import translation constants: smaller files + - And you can still get autocompletion and type-safety :rocket: + + **Before:** + ```typescript + import { trans } from '@symfony/ux-translator'; + import { SYMFONY_GREAT } from '@app/translations'; + + trans(SYMFONY_GREAT); + ``` + + **After:** + ```typescript + import { createTranslator } from '@symfony/ux-translator'; + import { messages } from '../var/translations/index.js'; + + const { trans } = createTranslator({ messages }); + trans('symfony.great'); + ``` + + The global functions (`setLocale`, `getLocale`, `setLocaleFallbacks`, `getLocaleFallbacks`, `throwWhenNotFound`) + have been replaced by a new `createTranslator()` factory function that returns an object with these methods. + + **Tree-shaking:** While tree-shaking of individual translation keys is no longer possible, modern build tools, + caching strategies, and compression techniques (Brotli, gzip) make this negligible in 2025. + A future feature will allow filtering dumped translations by pattern for those who need it, + further reducing bundle size. + + **For AssetMapper users:** You can remove the following entries from your `importmap.php`: + ```php + '@app/translations' => [ + 'path' => './var/translations/index.js', + ], + '@app/translations/configuration' => [ + 'path' => './var/translations/configuration.js', + ], + ``` + + **Note:** This is a breaking change, but the UX Translator component is still experimental. + ## 2.30 - Ensure compatibility with PHP 8.5 diff --git a/src/Translator/assets/dist/translator_controller.d.ts b/src/Translator/assets/dist/translator_controller.d.ts index e8d196c6838..c98285d5e6c 100644 --- a/src/Translator/assets/dist/translator_controller.d.ts +++ b/src/Translator/assets/dist/translator_controller.d.ts @@ -1,29 +1,41 @@ +type MessageId = string; type DomainType = string; type LocaleType = string; -type TranslationsType = Record; + +type TranslationsType = Record; type NoParametersType = Record; type ParametersType = Record | NoParametersType; + type RemoveIntlIcuSuffix = T extends `${infer U}+intl-icu` ? U : T; type DomainsOf = M extends Message ? keyof Translations : never; type LocaleOf = M extends Message ? Locale : never; -type ParametersOf = M extends Message ? Translations[D] extends { - parameters: infer Parameters; -} ? Parameters : never : never; +type ParametersOf = M extends Message + ? Translations[D] extends { parameters: infer Parameters } + ? Parameters + : never + : never; + interface Message { - id: string; translations: { [domain in DomainType]: { [locale in Locale]: string; }; }; } -declare function setLocale(locale: LocaleType | null): void; -declare function getLocale(): LocaleType; -declare function throwWhenNotFound(enabled: boolean): void; -declare function setLocaleFallbacks(localeFallbacks: Record): void; -declare function getLocaleFallbacks(): Record; -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 { type DomainType, type DomainsOf, type LocaleOf, type LocaleType, type Message, type NoParametersType, type ParametersOf, type ParametersType, type RemoveIntlIcuSuffix, type TranslationsType, getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, throwWhenNotFound, trans }; +type Messages = Record>; + +declare function getDefaultLocale(): LocaleType; +declare function createTranslator({ messages, locale, localeFallbacks, throwWhenNotFound, }: { + messages: TMessages; + locale?: LocaleType; + localeFallbacks?: Record; + throwWhenNotFound?: boolean; +}): { + setLocale(locale: LocaleType): void; + getLocale(): LocaleType; + setThrowWhenNotFound(throwWhenNotFound: boolean): void; + trans, TParameters extends ParametersOf>(id: TMessageId, parameters?: TParameters, domain?: RemoveIntlIcuSuffix | undefined, locale?: LocaleOf): string; +}; + +export { type DomainType, type DomainsOf, type LocaleOf, type LocaleType, type Message, type MessageId, type Messages, type NoParametersType, type ParametersOf, type ParametersType, type RemoveIntlIcuSuffix, type TranslationsType, createTranslator, getDefaultLocale }; diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js index e5019f8c85c..2a12d0d9298 100644 --- a/src/Translator/assets/dist/translator_controller.js +++ b/src/Translator/assets/dist/translator_controller.js @@ -62,12 +62,12 @@ function format(id, parameters, locale) { } function getPluralizationRule(number, locale) { number = Math.abs(number); - let _locale2 = locale; + let _locale = locale; if (locale === "pt_BR" || locale === "en_US_POSIX") { return 0; } - _locale2 = _locale2.length > 3 ? _locale2.substring(0, _locale2.indexOf("_")) : _locale2; - switch (_locale2) { + _locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf("_")) : _locale; + switch (_locale) { case "af": case "bn": case "bg": @@ -189,71 +189,78 @@ function formatIntl(id, parameters, locale) { } // src/translator_controller.ts -var _locale = null; -var _localeFallbacks = {}; -var _throwWhenNotFound = false; -function setLocale(locale) { - _locale = locale; -} -function getLocale() { - return _locale || document.documentElement.getAttribute("data-symfony-ux-translator-locale") || // +function getDefaultLocale() { + return document.documentElement.getAttribute("data-symfony-ux-translator-locale") || // (document.documentElement.lang ? document.documentElement.lang.replace("-", "_") : null) || // "en"; } -function throwWhenNotFound(enabled) { - _throwWhenNotFound = enabled; -} -function setLocaleFallbacks(localeFallbacks) { - _localeFallbacks = localeFallbacks; -} -function getLocaleFallbacks() { - return _localeFallbacks; -} -function trans(message, parameters = {}, domain = "messages", locale = null) { - if (typeof domain === "undefined") { - domain = "messages"; +function createTranslator({ + messages, + locale = getDefaultLocale(), + localeFallbacks = {}, + throwWhenNotFound = false +}) { + const _messages = messages; + const _localeFallbacks = localeFallbacks; + let _locale = locale; + let _throwWhenNotFound = throwWhenNotFound; + function setLocale(locale2) { + _locale = locale2; } - if (typeof locale === "undefined" || null === locale) { - locale = getLocale(); + function getLocale() { + return _locale; } - if (typeof message.translations === "undefined") { - return message.id; + function setThrowWhenNotFound(throwWhenNotFound2) { + _throwWhenNotFound = throwWhenNotFound2; } - 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; - } + function trans(id, parameters = {}, domain = "messages", locale2 = null) { + if (typeof domain === "undefined") { + domain = "messages"; } - if (locale) { - return formatIntl(translationsIntl[locale], parameters, locale); + if (typeof locale2 === "undefined" || null === locale2) { + locale2 = _locale; } - } - const translations = message.translations[domain]; - if (typeof translations !== "undefined") { - while (typeof translations[locale] === "undefined") { - locale = localesFallbacks[locale]; - if (!locale) { - break; + const message = _messages[id] ?? null; + if (message === null) { + return id; + } + const translationsIntl = message.translations[`${domain}+intl-icu`] ?? void 0; + if (typeof translationsIntl !== "undefined") { + while (typeof translationsIntl[locale2] === "undefined") { + locale2 = _localeFallbacks[locale2]; + if (!locale2) { + break; + } + } + if (locale2) { + return formatIntl(translationsIntl[locale2], parameters, locale2); } } - if (locale) { - return format(translations[locale], parameters, locale); + const translations = message.translations[domain] ?? void 0; + if (typeof translations !== "undefined") { + while (typeof translations[locale2] === "undefined") { + locale2 = _localeFallbacks[locale2]; + if (!locale2) { + break; + } + } + if (locale2) { + return format(translations[locale2], parameters, locale2); + } } + if (_throwWhenNotFound) { + throw new Error(`No translation message found with id "${id}".`); + } + return id; } - if (_throwWhenNotFound) { - throw new Error(`No translation message found with id "${message.id}".`); - } - return message.id; + return { + setLocale, + getLocale, + setThrowWhenNotFound, + trans + }; } export { - getLocale, - getLocaleFallbacks, - setLocale, - setLocaleFallbacks, - throwWhenNotFound, - trans + createTranslator, + getDefaultLocale }; diff --git a/src/Translator/assets/package.json b/src/Translator/assets/package.json index e4a12fcdf91..9e34c675834 100644 --- a/src/Translator/assets/package.json +++ b/src/Translator/assets/package.json @@ -27,9 +27,7 @@ "symfony": { "importmap": { "intl-messageformat": "^10.5.11", - "@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js", - "@app/translations": "path:var/translations/index.js", - "@app/translations/configuration": "path:var/translations/configuration.js" + "@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js" } }, "peerDependencies": { diff --git a/src/Translator/assets/src/translator_controller.ts b/src/Translator/assets/src/translator_controller.ts index 5bddc02d4ff..28f2dca0e0b 100644 --- a/src/Translator/assets/src/translator_controller.ts +++ b/src/Translator/assets/src/translator_controller.ts @@ -7,168 +7,176 @@ * file that was distributed with this source code. */ -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 { format } from './formatters/formatter'; import { formatIntl } from './formatters/intl-formatter'; +import type { DomainsOf, LocaleOf, LocaleType, MessageId, Messages, ParametersOf, RemoveIntlIcuSuffix } from './types'; -let _locale: LocaleType | null = null; -let _localeFallbacks: Record = {}; -let _throwWhenNotFound = false; - -export function setLocale(locale: LocaleType | null) { - _locale = locale; -} +export * from './types.d'; -export function getLocale(): LocaleType { +export function getDefaultLocale(): LocaleType { return ( - _locale || document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // (document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || // 'en' ); } -export function throwWhenNotFound(enabled: boolean): void { - _throwWhenNotFound = enabled; -} - -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>; +export function createTranslator({ + messages, + locale = getDefaultLocale(), + localeFallbacks = {}, + throwWhenNotFound = false, +}: { + messages: TMessages; + locale?: LocaleType; + localeFallbacks?: Record; + throwWhenNotFound?: boolean; +}): { + setLocale(locale: LocaleType): void; + getLocale(): LocaleType; + setThrowWhenNotFound(throwWhenNotFound: boolean): void; + trans< + TMessageId extends keyof TMessages & MessageId, + TMessage extends TMessages[TMessageId], + TDomain extends DomainsOf, + TParameters extends ParametersOf, + >( + id: TMessageId, + parameters?: TParameters, + domain?: RemoveIntlIcuSuffix | undefined, + locale?: LocaleOf + ): string; +} { + const _messages = messages; + const _localeFallbacks = localeFallbacks; + let _locale = locale; + let _throwWhenNotFound = throwWhenNotFound; + + /** + * Sets the locale. + */ + function setLocale(locale: LocaleType) { + _locale = locale; } - if (typeof locale === 'undefined' || null === locale) { - locale = getLocale() as LocaleOf; + /** + * Returns the current locale. + */ + function getLocale(): LocaleType { + return _locale; } - if (typeof message.translations === 'undefined') { - return message.id; + /** + * Sets whether an error should be thrown when a translation is not found. + */ + function setThrowWhenNotFound(throwWhenNotFound: boolean) { + _throwWhenNotFound = throwWhenNotFound; } - const localesFallbacks = getLocaleFallbacks(); + /** + * 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 id The message ID + * @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 + */ + function trans< + TMessageId extends keyof TMessages & MessageId, + TMessage extends TMessages[TMessageId], + TDomain extends DomainsOf, + TParameters extends ParametersOf, + >( + id: TMessageId, + parameters: TParameters = {} as TParameters, + domain: RemoveIntlIcuSuffix | undefined = 'messages' as RemoveIntlIcuSuffix, + locale: LocaleOf | null = null + ): string { + if (typeof domain === 'undefined') { + domain = 'messages' as RemoveIntlIcuSuffix; + } - 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 (typeof locale === 'undefined' || null === locale) { + locale = _locale as LocaleOf; } - if (locale) { - return formatIntl(translationsIntl[locale], parameters, locale); + const message = _messages[id] ?? (null as TMessage | null); + if (message === null) { + return id; } - } - const translations = message.translations[domain]; - if (typeof translations !== 'undefined') { - while (typeof translations[locale] === 'undefined') { - locale = localesFallbacks[locale] as LocaleOf; - if (!locale) { - break; + const translationsIntl = message.translations[`${domain}+intl-icu`] ?? undefined; + if (typeof translationsIntl !== 'undefined') { + while (typeof translationsIntl[locale] === 'undefined') { + locale = _localeFallbacks[locale] as LocaleOf; + if (!locale) { + break; + } + } + + if (locale) { + return formatIntl(translationsIntl[locale], parameters, locale); + } + } + + const translations = message.translations[domain] ?? undefined; + if (typeof translations !== 'undefined') { + while (typeof translations[locale] === 'undefined') { + locale = _localeFallbacks[locale] as LocaleOf; + if (!locale) { + break; + } + } + + if (locale) { + return format(translations[locale], parameters, locale); } } - if (locale) { - return format(translations[locale], parameters, locale); + if (_throwWhenNotFound) { + throw new Error(`No translation message found with id "${id}".`); } - } - if (_throwWhenNotFound) { - throw new Error(`No translation message found with id "${message.id}".`); + return id; } - return message.id; + return { + setLocale, + getLocale, + setThrowWhenNotFound, + trans, + }; } diff --git a/src/Translator/assets/src/types.d.ts b/src/Translator/assets/src/types.d.ts new file mode 100644 index 00000000000..51d65778026 --- /dev/null +++ b/src/Translator/assets/src/types.d.ts @@ -0,0 +1,26 @@ +export type MessageId = string; +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 { + translations: { + [domain in DomainType]: { + [locale in Locale]: string; + }; + }; +} + +export type Messages = Record>; diff --git a/src/Translator/assets/test/unit/translator_controller.test.ts b/src/Translator/assets/test/unit/translator_controller.test.ts index 44d92046ed3..20b8dc72d48 100644 --- a/src/Translator/assets/test/unit/translator_controller.test.ts +++ b/src/Translator/assets/test/unit/translator_controller.test.ts @@ -1,110 +1,108 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { - getLocale, - type Message, - type NoParametersType, - setLocale, - setLocaleFallbacks, - throwWhenNotFound, - trans, -} from '../../src/translator_controller'; +import { createTranslator } from '../../src/translator_controller'; +import type { Message, NoParametersType } from '../../src/types'; describe('Translator', () => { beforeEach(() => { - setLocale(null); - setLocaleFallbacks({}); - throwWhenNotFound(false); document.documentElement.lang = ''; document.documentElement.removeAttribute('data-symfony-ux-translator-locale'); }); - describe('getLocale', () => { + describe('create translator with locale', () => { test('default locale', () => { + let translator = createTranslator({ messages: {} }); + // 'en' is the default locale - expect(getLocale()).toEqual('en'); + expect(translator.getLocale()).toEqual('en'); // or the locale from , if exists document.documentElement.lang = 'fr'; - expect(getLocale()).toEqual('fr'); + translator = createTranslator({ messages: {} }); + expect(translator.getLocale()).toEqual('fr'); // or the locale from , if exists document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it'); - expect(getLocale()).toEqual('it'); + translator = createTranslator({ messages: {} }); + expect(translator.getLocale()).toEqual('it'); + }); - setLocale('de'); - expect(getLocale()).toEqual('de'); + test('custom locale', () => { + document.documentElement.lang = 'fr'; + document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it'); + + const translator = createTranslator({ messages: {}, locale: 'de' }); + expect(translator.getLocale()).toEqual('de'); }); - }); - describe('getLocale', () => { test('with subcode', () => { // allow format according to W3C document.documentElement.lang = 'de-AT'; - expect(getLocale()).toEqual('de_AT'); + const translator = createTranslator({ messages: {} }); + expect(translator.getLocale()).toEqual('de_AT'); // or "incorrect" Symfony locale format document.documentElement.lang = 'de_AT'; - expect(getLocale()).toEqual('de_AT'); - }); - }); - - describe('setLocale', () => { - test('custom locale', () => { - setLocale('fr'); - - expect(getLocale()).toEqual('fr'); + expect(translator.getLocale()).toEqual('de_AT'); }); }); describe('trans', () => { test('basic message', () => { - const MESSAGE_BASIC: Message<{ messages: { parameters: NoParametersType } }, 'en'> = { - id: 'message.basic', - translations: { - messages: { - en: 'A basic message', + const messages: { + 'message.basic': Message<{ messages: { parameters: NoParametersType } }, 'en'>; + } = { + 'message.basic': { + translations: { + messages: { + en: 'A basic message', + }, }, }, }; + const translator = createTranslator({ messages }); - 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'); + expect(translator.trans('message.basic')).toEqual('A basic message'); + expect(translator.trans('message.basic', {})).toEqual('A basic message'); + expect(translator.trans('message.basic', {}, 'messages')).toEqual('A basic message'); + expect(translator.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'); + expect(translator.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'); + expect(translator.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'); + expect(translator.trans('message.basic', {}, 'messages', 'fr')).toEqual('message.basic'); }); test('basic message with parameters', () => { - 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%', + const messages: { + 'message.basic.with.parameters': Message< + { messages: { parameters: { '%parameter1%': string; '%parameter2%': string } } }, + 'en' + >; + } = { + 'message.basic.with.parameters': { + translations: { + messages: { + en: 'A basic message %parameter1% %parameter2%', + }, }, }, }; + const translator = createTranslator({ messages }); expect( - trans(MESSAGE_BASIC_WITH_PARAMETERS, { + translator.trans('message.basic.with.parameters', { '%parameter1%': 'foo', '%parameter2%': 'bar', }) ).toEqual('A basic message foo bar'); expect( - trans( - MESSAGE_BASIC_WITH_PARAMETERS, + translator.trans( + 'message.basic.with.parameters', { '%parameter1%': 'foo', '%parameter2%': 'bar', @@ -114,8 +112,8 @@ describe('Translator', () => { ).toEqual('A basic message foo bar'); expect( - trans( - MESSAGE_BASIC_WITH_PARAMETERS, + translator.trans( + 'message.basic.with.parameters', { '%parameter1%': 'foo', '%parameter2%': 'bar', @@ -126,82 +124,90 @@ describe('Translator', () => { ).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%'); + expect(translator.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( + expect(translator.trans('message.basic.with.parameters', { '%parameter1%': 'foo' })).toEqual( 'A basic message foo %parameter2%' ); expect( - trans( - MESSAGE_BASIC_WITH_PARAMETERS, + translator.trans( + 'message.basic.with.parameters', { '%parameter1%': 'foo', '%parameter2%': 'bar', - // @ts-expect-error "foobar" is not a valid domain }, + // @ts-expect-error "foobar" is not a valid domain 'foobar' ) ).toEqual('message.basic.with.parameters'); expect( - trans( - MESSAGE_BASIC_WITH_PARAMETERS, + translator.trans( + 'message.basic.with.parameters', { '%parameter1%': 'foo', '%parameter2%': 'bar', - // @ts-expect-error "fr" is not a valid locale }, 'messages', + // @ts-expect-error "fr" is not a valid locale 'fr' ) ).toEqual('message.basic.with.parameters'); }); test('intl message', () => { - const MESSAGE_INTL: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'> = { - id: 'message.intl', - translations: { - 'messages+intl-icu': { - en: 'An intl message', + const messages: { + 'message.intl': Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'>; + } = { + 'message.intl': { + translations: { + 'messages+intl-icu': { + en: 'An intl message', + }, }, }, }; + const translator = createTranslator({ messages }); - 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'); + expect(translator.trans('message.intl')).toEqual('An intl message'); + expect(translator.trans('message.intl', {})).toEqual('An intl message'); + expect(translator.trans('message.intl', {}, 'messages')).toEqual('An intl message'); + expect(translator.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'); + expect(translator.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'); + expect(translator.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'); + expect(translator.trans('message.intl', {}, 'messages', 'fr')).toEqual('message.intl'); }); test('intl message with parameters', () => { - const INTL_MESSAGE_WITH_PARAMETERS: Message< - { - 'messages+intl-icu': { - parameters: { - gender_of_host: 'male' | 'female' | string; - num_guests: number; - host: string; - guest: string; + const messages: { + 'message.intl.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: ` + }, + 'en' + >; + } = { + '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.} @@ -218,12 +224,14 @@ describe('Translator', () => { =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 translator = createTranslator({ messages }); expect( - trans(INTL_MESSAGE_WITH_PARAMETERS, { + translator.trans('message.intl.with.parameters', { gender_of_host: 'male', num_guests: 123, host: 'John', @@ -232,8 +240,8 @@ describe('Translator', () => { ).toEqual('John invites Mary as one of the 122 people invited to his party.'); expect( - trans( - INTL_MESSAGE_WITH_PARAMETERS, + translator.trans( + 'message.intl.with.parameters', { gender_of_host: 'female', num_guests: 44, @@ -245,8 +253,8 @@ describe('Translator', () => { ).toEqual('Mary invites John as one of the 43 people invited to her party.'); expect( - trans( - INTL_MESSAGE_WITH_PARAMETERS, + translator.trans( + 'message.intl.with.parameters', { gender_of_host: 'female', num_guests: 1, @@ -260,19 +268,19 @@ describe('Translator', () => { expect(() => { // @ts-expect-error Parameters "gender_of_host", "num_guests", "host", and "guest" are missing - trans(INTL_MESSAGE_WITH_PARAMETERS, {}); + translator.trans('message.intl.with.parameters', {}); }).toThrow(/^The intl string context variable "gender_of_host" was not provided/); expect(() => { // @ts-expect-error Parameters "num_guests", "host", and "guest" are missing - trans(INTL_MESSAGE_WITH_PARAMETERS, { + translator.trans('message.intl.with.parameters', { gender_of_host: 'male', }); }).toThrow(/^The intl string context variable "num_guests" was not provided/); expect(() => { // @ts-expect-error Parameters "host", and "guest" are missing - trans(INTL_MESSAGE_WITH_PARAMETERS, { + translator.trans('message.intl.with.parameters', { gender_of_host: 'male', num_guests: 123, }); @@ -280,7 +288,7 @@ describe('Translator', () => { expect(() => { // @ts-expect-error Parameter "guest" is missing - trans(INTL_MESSAGE_WITH_PARAMETERS, { + translator.trans('message.intl.with.parameters', { gender_of_host: 'male', num_guests: 123, host: 'John', @@ -288,8 +296,8 @@ describe('Translator', () => { }).toThrow(/^The intl string context variable "guest" was not provided/); expect( - trans( - INTL_MESSAGE_WITH_PARAMETERS, + translator.trans( + 'message.intl.with.parameters', { gender_of_host: 'male', num_guests: 123, @@ -302,8 +310,8 @@ describe('Translator', () => { ).toEqual('message.intl.with.parameters'); expect( - trans( - INTL_MESSAGE_WITH_PARAMETERS, + translator.trans( + 'message.intl.with.parameters', { gender_of_host: 'male', num_guests: 123, @@ -318,197 +326,296 @@ describe('Translator', () => { }); test('same message id for multiple domains', () => { - 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', + const messages: { + 'message.multi_domains': Message< + { foobar: { parameters: NoParametersType }; messages: { parameters: NoParametersType } }, + 'en' + >; + } = { + 'message.multi_domains': { + translations: { + foobar: { + en: 'A message from foobar catalogue', + }, + messages: { + en: 'A message from messages catalogue', + }, }, }, }; + const translator = createTranslator({ messages }); - 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(translator.trans('message.multi_domains')).toEqual('A message from messages catalogue'); + expect(translator.trans('message.multi_domains', {})).toEqual('A message from messages catalogue'); + expect(translator.trans('message.multi_domains', {}, 'messages')).toEqual( + 'A message from messages catalogue' + ); + expect(translator.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'); + expect(translator.trans('message.multi_domains', {}, 'messages', 'en')).toEqual( + 'A message from messages catalogue' + ); + expect(translator.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'); + expect(translator.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'); + expect(translator.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'); + expect(translator.trans('message.multi_domains', {}, 'foobar', 'fr')).toEqual('message.multi_domains'); }); test('same message id for multiple domains, and different parameters', () => { - 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%', + const messages: { + 'message.multi_domains.different_parameters': Message< + { + foobar: { parameters: { '%parameter2%': string } }; + messages: { parameters: { '%parameter1%': string } }; }, - messages: { - en: 'A message from messages catalogue with a parameter %parameter1%', + 'en' + >; + } = { + '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%', + }, }, }, }; + const translator = createTranslator({ messages }); - 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( + expect(translator.trans('message.multi_domains.different_parameters', { '%parameter1%': 'foo' })).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' - ); + expect( + translator.trans('message.multi_domains.different_parameters', { '%parameter1%': 'foo' }, 'messages') + ).toEqual('A message from messages catalogue with a parameter foo'); + expect( + translator.trans( + 'message.multi_domains.different_parameters', + { '%parameter1%': 'foo' }, + 'messages', + 'en' + ) + ).toEqual('A message from messages catalogue with a parameter foo'); + expect( + translator.trans('message.multi_domains.different_parameters', { '%parameter2%': 'foo' }, 'foobar') + ).toEqual('A message from foobar catalogue with a parameter foo'); + expect( + translator.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_WITH_PARAMETERS, {})).toEqual( + expect(translator.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_WITH_PARAMETERS, { '%parameter1%': 'foo' }, 'baz')).toEqual( - 'message.multi_domains.different_parameters' - ); + expect( + // @ts-expect-error Domain "baz" is invalid + translator.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_WITH_PARAMETERS, { '%parameter1%': 'foo' }, 'messages', 'fr')).toEqual( - 'message.multi_domains.different_parameters' - ); + expect( + translator.trans( + 'message.multi_domains.different_parameters', + { '%parameter1%': 'foo' }, + 'messages', + // @ts-expect-error Locale "fr" is invalid + 'fr' + ) + ).toEqual('message.multi_domains.different_parameters'); }); test('missing message should return the message id when `throwWhenNotFound` is false', () => { - throwWhenNotFound(false); - setLocale('fr'); - - const MESSAGE_IN_ANOTHER_DOMAIN: Message<{ security: { parameters: NoParametersType } }, 'en'> = { - id: 'Invalid credentials.', - translations: { - messages: { - en: 'Invalid credentials.', + const messages: { + 'message.id': Message<{ security: { parameters: NoParametersType } }, 'en'>; + } = { + 'message.id': { + translations: { + messages: { + en: 'Invalid credentials.', + }, }, }, }; - expect(trans(MESSAGE_IN_ANOTHER_DOMAIN)).toEqual('Invalid credentials.'); + const translator = createTranslator({ + messages, + locale: 'fr', + throwWhenNotFound: false, + }); + + expect(translator.trans('message.id')).toEqual('message.id'); }); test('missing message should throw an error if `throwWhenNotFound` is true', () => { - throwWhenNotFound(true); - setLocale('fr'); - - const MESSAGE_IN_ANOTHER_DOMAIN: Message<{ security: { parameters: NoParametersType } }, 'en'> = { - id: 'Invalid credentials.', - translations: { - messages: { - en: 'Invalid credentials.', + const messages: { + 'message.id': Message<{ security: { parameters: NoParametersType } }, 'en'>; + } = { + 'message.id': { + translations: { + messages: { + en: 'Invalid credentials.', + }, }, }, }; + const translator = createTranslator({ + messages, + locale: 'fr', + throwWhenNotFound: true, + }); + expect(() => { - trans(MESSAGE_IN_ANOTHER_DOMAIN); - }).toThrow(`No translation message found with id "Invalid credentials.".`); + translator.trans('message.id'); + }).toThrow(`No translation message found with id "message.id".`); }); test('message from intl domain should be prioritized over its non-intl equivalent', () => { - const MESSAGE: Message< - { 'messages+intl-icu': { parameters: NoParametersType }; messages: { parameters: NoParametersType } }, - 'en' - > = { - id: 'message', - translations: { - 'messages+intl-icu': { - en: 'A intl message', + const messages: { + message: Message< + { + 'messages+intl-icu': { parameters: NoParametersType }; + messages: { parameters: NoParametersType }; }, - messages: { - en: 'A basic message', + 'en' + >; + } = { + message: { + translations: { + 'messages+intl-icu': { + en: 'A intl message', + }, + messages: { + en: 'A basic message', + }, }, }, }; + const translator = createTranslator({ messages }); - 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'); + expect(translator.trans('message')).toEqual('A intl message'); + expect(translator.trans('message', {})).toEqual('A intl message'); + expect(translator.trans('message', {}, 'messages')).toEqual('A intl message'); + expect(translator.trans('message', {}, 'messages', 'en')).toEqual('A intl message'); }); test('fallback behavior', () => { - 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 messages: { + message: Message<{ messages: { parameters: NoParametersType } }, 'en' | 'en_US' | 'fr'>; + message_intl: Message<{ messages: { parameters: NoParametersType } }, 'en' | 'en_US' | 'fr'>; + message_french_only: Message<{ messages: { parameters: NoParametersType } }, 'fr'>; + } = { + 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', + 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', + }, + }, + }, + message_french_only: { + translations: { + messages: { + fr: 'Un message en français uniquement', + }, }, }, }; + const translator = createTranslator({ + messages, + localeFallbacks: { + fr_FR: 'fr', + fr: 'en', + en_US: 'en', + en_GB: 'en', + de_DE: 'de', + de: 'en', + }, + }); + + expect(translator.trans('message', {}, 'messages', 'en')).toEqual('A message in english'); + expect(translator.trans('message_intl', {}, 'messages', 'en')).toEqual('A intl message in english'); + expect(translator.trans('message', {}, 'messages', 'en_US')).toEqual('A message in english (US)'); + expect(translator.trans('message_intl', {}, 'messages', 'en_US')).toEqual('A intl message in english (US)'); + expect(translator.trans('message', {}, 'messages', 'en_GB' as 'en')).toEqual('A message in english'); + expect(translator.trans('message_intl', {}, 'messages', 'en_GB' as 'en')).toEqual( + 'A intl message in english' + ); + + expect(translator.trans('message', {}, 'messages', 'fr')).toEqual('Un message en français'); + expect(translator.trans('message_intl', {}, 'messages', 'fr')).toEqual('Un message intl en français'); + expect(translator.trans('message', {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message en français'); + expect(translator.trans('message_intl', {}, 'messages', 'fr_FR' as 'fr')).toEqual( + 'Un message intl en français' + ); + + expect(translator.trans('message', {}, 'messages', 'de_DE' as 'en')).toEqual('A message in english'); + expect(translator.trans('message_intl', {}, 'messages', 'de_DE' as 'en')).toEqual( + 'A intl message in english' + ); + + expect(translator.trans('message_french_only', {}, 'messages', 'fr')).toEqual( + 'Un message en français uniquement' + ); + expect(translator.trans('message_french_only', {}, 'messages', 'en' as 'fr')).toEqual( + 'message_french_only' + ); + }); + }); - const MESSAGE_FRENCH_ONLY: Message<{ messages: { parameters: NoParametersType } }, 'fr'> = { - id: 'message_french_only', - translations: { - messages: { - fr: 'Un message en français uniquement', + describe('destructuring', () => { + test('createTranslator returns expected methods', () => { + const messages: { + 'message.basic': Message<{ messages: { parameters: NoParametersType } }, 'en' | 'fr'>; + } = { + 'message.basic': { + translations: { + messages: { + en: 'A basic message', + fr: 'Un message basique', + }, }, }, }; + const translator = createTranslator({ messages }); - 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'); + const { setLocale, getLocale, setThrowWhenNotFound, trans } = translator; - 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(typeof setLocale).toBe('function'); + expect(typeof getLocale).toBe('function'); + expect(typeof setThrowWhenNotFound).toBe('function'); + expect(typeof trans).toBe('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'); + expect(getLocale()).toEqual('en'); + expect(trans('message.basic')).toEqual('A basic message'); + setLocale('fr'); + expect(getLocale()).toEqual('fr'); + expect(trans('message.basic')).toEqual('Un message basique'); }); }); }); diff --git a/src/Translator/doc/index.rst b/src/Translator/doc/index.rst index 0c0da4facc1..53a49e7d186 100644 --- a/src/Translator/doc/index.rst +++ b/src/Translator/doc/index.rst @@ -15,7 +15,7 @@ Installation .. note:: This package works best with WebpackEncore. To use it with AssetMapper, see - :ref:`Using with AssetMapper `. + :ref:`Using with AssetMapper`_. .. caution:: @@ -27,18 +27,6 @@ Install the bundle using Composer and Symfony Flex: $ composer require symfony/ux-translator -If you're using WebpackEncore, install your assets and restart Encore (not -needed if you're using AssetMapper): - -.. code-block:: terminal - - $ npm install --force - $ npm run watch - -.. note:: - - For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-translator npm package`_ - After installing the bundle, the following file should be created, thanks to the Symfony Flex recipe: .. code-block:: javascript @@ -54,13 +42,43 @@ After installing the bundle, the following file should be created, thanks to the * 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'; + import { createTranslator } from '@symfony/ux-translator'; + import { messages, localeFallbacks } from '../var/translations/index.js'; + + const translator = createTranslator({ + messages, + localeFallbacks, + }); + + // Allow you to use `import { trans } from './translator';` in your assets + export const { trans } = translator; - setLocaleFallbacks(localeFallbacks); +Using with WebpackEncore +~~~~~~~~~~~~~~~~~~~~~~~~ - export { trans } - export * from '../var/translations'; +If you're using WebpackEncore, install your assets and restart Encore (not +needed if you're using AssetMapper): + +.. code-block:: terminal + + $ npm install --force + $ npm run watch + +.. note:: + + For more complex installation scenarios, you can install the JavaScript assets through the `@symfony/ux-translator npm package`_ + +Using with AssetMapper +~~~~~~~~~~~~~~~~~~~~~~ + +Using this library with AssetMapper is possible. + +When installing with AssetMapper, Flex will add a new item to your ``importmap.php`` +file:: + + '@symfony/ux-translator' => [ + 'path' => './vendor/symfony/ux-translator/assets/dist/translator_controller.js', + ], Usage ----- @@ -68,8 +86,8 @@ Usage When warming up the Symfony cache, 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 `_. +Then, you will be able to import the ``trans()`` function in your assets and use translation keys as simple strings, +exactly as you would in your Symfony PHP code. Configuring the dumped translations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -94,7 +112,8 @@ Configuring the default locale By default, the default locale is ``en`` (English) that you can configure through many ways (in order of priority): -#. With ``setLocale('de')`` or ``setLocale('de_AT')`` from ``@symfony/ux-translator`` package +#. By passing the locale directly to the ``createTranslator()`` function +#. With ``setLocale('de')`` or ``setLocale('de_AT')`` from your ``assets/translator.js`` file #. Or with ```` attribute (e.g., ``de_AT`` or ``de`` using Symfony locale format) #. Or with ```` attribute (e.g., ``de-AT`` or ``de`` following the `W3C specification on language codes`_) @@ -103,86 +122,71 @@ Detecting missing translations By default, the translator will return the translation key if the translation is missing. -You can change this behavior by calling ``throwWhenNotFound(true)``: +You can change this behavior by calling ``setThrowWhenNotFound(true)``: .. code-block:: diff // assets/translator.js - - import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator'; - + import { trans, getLocale, setLocale, setLocaleFallbacks, throwWhenNotFound } from '@symfony/ux-translator'; - import { localeFallbacks } from '../var/translations/configuration'; + import { createTranslator } from '@symfony/ux-translator'; + import { messages, localeFallbacks } from '../var/translations/index.js'; - setLocaleFallbacks(localeFallbacks); - + throwWhenNotFound(true) + const translator = createTranslator({ + messages, + localeFallbacks, + + throwWhenNotFound: true, // either when creating the translator + }); - export { trans } - export * from '../var/translations'; + + // Or later in your code + export const { trans, setThrowWhenNotFound } = translator; 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``. +If you use the Symfony Flex recipe, you can import the ``trans()`` function 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: +You can then use translation keys as simple strings, exactly as you would in your Symfony PHP code: .. code-block:: javascript // assets/my_file.js - import { - trans, - TRANSLATION_SIMPLE, - TRANSLATION_WITH_PARAMETERS, - TRANSLATION_MULTI_DOMAINS, - TRANSLATION_MULTI_LOCALES, - } from './translator'; + import { trans } from './translator'; // No parameters, uses the default domain ("messages") and the default locale - trans(TRANSLATION_SIMPLE); + 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' }); + trans('translation.with.parameters', { count: 123, foo: 'bar' }); // No parameters, uses the default domain ("messages") and the default locale - trans(TRANSLATION_MULTI_DOMAINS); + trans('translation.multi.domains'); // Same as above, but uses the "domain2" domain - trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain2'); + trans('translation.multi.domains', {}, 'domain2'); // Same as above, but uses the "domain3" domain - trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain3'); + trans('translation.multi.domains', {}, 'domain3'); // No parameters, uses the default domain ("messages") and the default locale - trans(TRANSLATION_MULTI_LOCALES); + trans('translation.multi.locales'); // Same as above, but uses the "fr" locale - trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'fr'); + trans('translation.multi.locales', {}, 'messages', 'fr'); // Same as above, but uses the "it" locale - trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'it'); + trans('translation.multi.locales', {}, 'messages', 'it'); -.. _using-with-asset-mapper: +You will get autocompletion and type-safety for translation keys, parameters, domains, and locales. -Using with AssetMapper ----------------------- - -Using this library with AssetMapper is possible, but is currently experimental -and may not be ready yet for production. +Q&A +--- -When installing with AssetMapper, Flex will add a few new items to your ``importmap.php`` -file. 2 of the new items are:: +What about bundle size? +~~~~~~~~~~~~~~~~~~~~~~~ - '@app/translations' => [ - 'path' => 'var/translations/index.js', - ], - '@app/translations/configuration' => [ - 'path' => 'var/translations/configuration.js', - ], +All your translations (extracted from the configured domains) are included in the generated ``var/translations/index.js`` file, +which means they will be included in your final JavaScript bundle). -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. +However, modern build tools, caching strategies, and compression techniques (Brotli, gzip) +make this negligible in 2025. Additionally, a future feature will allow filtering dumped +translations by pattern for those who need to further reduce bundle size. Backward Compatibility promise ------------------------------ diff --git a/src/Translator/src/TranslationsDumper.php b/src/Translator/src/TranslationsDumper.php index 811a0c00fdf..e2e9f68ab82 100644 --- a/src/Translator/src/TranslationsDumper.php +++ b/src/Translator/src/TranslationsDumper.php @@ -17,8 +17,6 @@ use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor; use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter; -use function Symfony\Component\String\s; - /** * @author Hugo Alliaume * @@ -34,7 +32,6 @@ class TranslationsDumper { private array $excludedDomains = []; private array $includedDomains = []; - private array $alreadyGeneratedConstants = []; public function __construct( private string $dumpDir, @@ -48,44 +45,50 @@ public function __construct( 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'); + $this->filesystem->remove($fileIndexJs = $this->dumpDir.'/index.js'); + $this->filesystem->remove($fileIndexDts = $this->dumpDir.'/index.d.ts'); - $translationsJs = ''; - $translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n"; + $this->filesystem->appendToFile( + $fileIndexJs, + \sprintf(<<<'JS' + // This file is auto-generated by the Symfony UX Translator. Do not edit it manually. - 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) - ); - } + export const localeFallbacks = %s; + + JS, + json_encode($this->getLocaleFallbacks(...$catalogues), \JSON_THROW_ON_ERROR) + )); - $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 + $this->filesystem->appendToFile( + $fileIndexDts, + <<<'TS' + // This file is auto-generated by the Symfony UX Translator. Do not edit it manually. + import { Message, NoParametersType, LocaleType } from '@symfony/ux-translator'; + + export declare const localeFallbacks: Record; + + TS ); + + $this->filesystem->appendToFile($fileIndexJs, 'export const messages = {'."\n"); + $this->filesystem->appendToFile($fileIndexDts, 'export declare const messages: {'."\n"); + foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) { + $translationId = str_replace('"', '\\"', $translationId); + $this->filesystem->appendToFile($fileIndexJs, \sprintf( + ' "%s": %s,%s', + $translationId, + json_encode(['translations' => $translationsByDomainAndLocale], \JSON_THROW_ON_ERROR), + "\n" + )); + $this->filesystem->appendToFile($fileIndexDts, \sprintf( + ' "%s": %s;%s', + $translationId, + $this->getTranslationsTypeScriptTypeDefinition($translationsByDomainAndLocale), + "\n" + )); + } + $this->filesystem->appendToFile($fileIndexJs, '};'."\n"); + $this->filesystem->appendToFile($fileIndexDts, '};'."\n"); } public function addExcludedDomain(string $domain): void @@ -182,18 +185,4 @@ private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): a return $localesFallbacks; } - - private function generateConstantName(string $translationId): string - { - $translationId = s($translationId)->ascii()->snake()->upper()->replaceMatches('/^(\d)/', '_$1')->toString(); - $prefix = 0; - do { - $constantName = $translationId.($prefix > 0 ? '_'.$prefix : ''); - ++$prefix; - } while ($this->alreadyGeneratedConstants[$constantName] ?? false); - - $this->alreadyGeneratedConstants[$constantName] = true; - - return $constantName; - } } diff --git a/src/Translator/tests/Functional/DumpEnabledLocalesTest.php b/src/Translator/tests/Functional/DumpEnabledLocalesTest.php index 0083468af25..2d241eaace7 100644 --- a/src/Translator/tests/Functional/DumpEnabledLocalesTest.php +++ b/src/Translator/tests/Functional/DumpEnabledLocalesTest.php @@ -35,34 +35,28 @@ public function testShouldDumpOnlyEnabledLocales() self::assertStringEqualsFile( $translationsDumpDir.'/index.js', - <<; - - TYPESCRIPT - ); - self::assertStringEqualsFile( - $translationsDumpDir.'/configuration.js', - <<; - TYPESCRIPT + export declare const messages: { + "symfony_ux.great": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + }; + + TS ); } } diff --git a/src/Translator/tests/TranslationsDumperTest.php b/src/Translator/tests/TranslationsDumperTest.php index 679635a8b63..08c71b7334f 100644 --- a/src/Translator/tests/TranslationsDumperTest.php +++ b/src/Translator/tests/TranslationsDumperTest.php @@ -52,57 +52,66 @@ public function testDump() $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 POST_NUM_COMMENTS_1 = {"id":"post.num_comments.","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; - export const POST_NUM_COMMENTS_2 = {"id":"post.num_comments..","translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; - 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 SYMFONY_WHAT_1 = {"id":"symfony.what!","translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; - export const SYMFONY_WHAT_2 = {"id":"symfony.what.","translations":{"messages":{"en":"Symfony is %what%. (should also not conflict with the previous one.)","fr":"Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; - 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%"}}}; - export const ANIMAL_DOG_CAT = {"id":"animal.dog-cat","translations":{"messages":{"en":"Dog and cat","fr":"Chien et chat"}}}; - export const ANIMAL_DOG_CAT_1 = {"id":"animal.dog_cat","translations":{"messages":{"en":"Dog and cat (should not conflict with the previous one)","fr":"Chien et chat (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}; - export const _0STARTS_WITH_NUMERIC = {"id":"0starts.with.numeric","translations":{"messages":{"en":"Key starts with numeric char","fr":"La touche commence par un caract\u00e8re num\u00e9rique"}}}; - - 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 POST_NUM_COMMENTS_1: Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>; - export declare const POST_NUM_COMMENTS_2: Message<{ 'messages+intl-icu': { 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 SYMFONY_WHAT_1: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; - export declare const SYMFONY_WHAT_2: 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'>; - export declare const ANIMAL_DOG_CAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; - export declare const ANIMAL_DOG_CAT_1: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; - export declare const _0STARTS_WITH_NUMERIC: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; - - TYPESCRIPT); + $this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JS' + // This file is auto-generated by the Symfony UX Translator. Do not edit it manually. + + export const localeFallbacks = {"en":null,"fr":null}; + export const messages = { + "notification.comment_created": {"translations":{"messages+intl-icu":{"en":"Your post received a comment!","fr":"Votre article a re\u00e7u un commentaire !"}}}, + "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>"}}}, + "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"}}}, + "post.num_comments.": {"translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}, + "post.num_comments..": {"translations":{"messages+intl-icu":{"en":"{count, plural, one {# comment} other {# comments}} (should not conflict with the previous one.)","fr":"{count, plural, one {# commentaire} other {# commentaires}} (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}, + "symfony.great": {"translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}}, + "symfony.what": {"translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}}, + "symfony.what!": {"translations":{"messages":{"en":"Symfony is %what%! (should not conflict with the previous one.)","fr":"Symfony est %what%! (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}, + "symfony.what.": {"translations":{"messages":{"en":"Symfony is %what%. (should also not conflict with the previous one.)","fr":"Symfony est %what%. (ne doit pas non plus rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}, + "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"}}}, + "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"}}}, + "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"}}}, + "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"}}}, + "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"}}}, + "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%"}}}, + "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%"}}}, + "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%"}}}, + "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%"}}}, + "animal.dog-cat": {"translations":{"messages":{"en":"Dog and cat","fr":"Chien et chat"}}}, + "animal.dog_cat": {"translations":{"messages":{"en":"Dog and cat (should not conflict with the previous one)","fr":"Chien et chat (ne doit pas rentrer en conflit avec la traduction pr\u00e9c\u00e9dente)"}}}, + "0starts.with.numeric": {"translations":{"messages":{"en":"Key starts with numeric char","fr":"La touche commence par un caract\u00e8re num\u00e9rique"}}}, + }; + + JS); + + $this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TS' + // This file is auto-generated by the Symfony UX Translator. Do not edit it manually. + import { Message, NoParametersType, LocaleType } from '@symfony/ux-translator'; + + export declare const localeFallbacks: Record; + export declare const messages: { + "notification.comment_created": Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'|'fr'>; + "notification.comment_created.description": Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>; + "post.num_comments": Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>; + "post.num_comments.": Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>; + "post.num_comments..": Message<{ 'messages+intl-icu': { parameters: { 'count': number } } }, 'en'|'fr'>; + "symfony.great": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + "symfony.what": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; + "symfony.what!": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; + "symfony.what.": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; + "apples.count.0": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; + "apples.count.1": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; + "apples.count.2": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; + "apples.count.3": Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>; + "apples.count.4": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + "what.count.1": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; + "what.count.2": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; + "what.count.3": Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>; + "what.count.4": Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>; + "animal.dog-cat": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + "animal.dog_cat": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + "0starts.with.numeric": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>; + }; + + TS); } public function testDumpWithExcludedDomains() diff --git a/src/Translator/tests/bootstrap.php b/src/Translator/tests/bootstrap.php index 1172565510f..7c374094180 100644 --- a/src/Translator/tests/bootstrap.php +++ b/src/Translator/tests/bootstrap.php @@ -20,6 +20,7 @@ $kernel = new FrameworkAppKernel('test', true); $application = new Application($kernel); +$application->setAutoExit(false); // Trigger Symfony Translator and UX Translator cache warmers -$application->run(new StringInput('cache:clear')); +$application->run(new StringInput('cache:clear -vv')); From fc0ccfa5ae5df2a8941f2c5aa32221ef8e283610 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Wed, 3 Dec 2025 21:12:47 +0100 Subject: [PATCH 2/4] Update E2E app --- apps/e2e/README.md | 6 +++--- apps/e2e/assets/app.js | 4 ++++ apps/e2e/assets/translator.js | 14 ++++++++------ apps/e2e/importmap.php | 6 ------ apps/e2e/templates/ux_translator/basic.html.twig | 7 +++---- .../ux_translator/icu_date_time.html.twig | 7 +++---- .../ux_translator/icu_number_currency.html.twig | 7 +++---- .../ux_translator/icu_number_percent.html.twig | 7 +++---- .../templates/ux_translator/icu_plural.html.twig | 7 +++---- .../templates/ux_translator/icu_select.html.twig | 7 +++---- .../ux_translator/icu_selectordinal.html.twig | 7 +++---- .../ux_translator/with_parameter.html.twig | 7 +++---- 12 files changed, 39 insertions(+), 47 deletions(-) diff --git a/apps/e2e/README.md b/apps/e2e/README.md index 4795b30333d..1205bf2a9eb 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -1,8 +1,8 @@ # E2E App -This is a Symfony application designed for end-to-end testing. +This is a Symfony application designed for end-to-end testing. -It serves for testing UX packages in a real-world scenario, +It serves for testing UX packages in a real-world scenario, to ensure they work as expected for multiple Symfony versions and various browsers. ## Requirements @@ -16,7 +16,7 @@ to ensure they work as expected for multiple Symfony versions and various browse ```shell docker compose up -d -symfony php ../.github/build-packages.php +symfony php ../../.github/build-packages.php SYMFONY_REQUIRE=6.4.* symfony composer update # or... diff --git a/apps/e2e/assets/app.js b/apps/e2e/assets/app.js index 5b1ceb0c4e7..a237aff12dd 100644 --- a/apps/e2e/assets/app.js +++ b/apps/e2e/assets/app.js @@ -2,6 +2,8 @@ import { registerVueControllerComponents } from '@symfony/ux-vue'; import { registerSvelteControllerComponents } from '@symfony/ux-svelte'; import { registerReactControllerComponents } from '@symfony/ux-react'; import './bootstrap.js'; +import { trans } from "./translator.js"; + /* * Welcome to your app's main JavaScript file! * @@ -16,3 +18,5 @@ console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); registerReactControllerComponents(); registerSvelteControllerComponents(); registerVueControllerComponents(); + +export { trans }; diff --git a/apps/e2e/assets/translator.js b/apps/e2e/assets/translator.js index a0efa830ae4..320d791650b 100644 --- a/apps/e2e/assets/translator.js +++ b/apps/e2e/assets/translator.js @@ -1,5 +1,6 @@ -import { localeFallbacks } from '@app/translations/configuration'; -import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator'; +import { createTranslator } from "@symfony/ux-translator"; +import { messages, localeFallbacks } from "../var/translations/index.js"; + /* * This file is part of the Symfony UX Translator package. * @@ -9,8 +10,9 @@ import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-tra * If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking. */ -setLocaleFallbacks(localeFallbacks); - -export { trans }; +export const translator = createTranslator({ + messages, + localeFallbacks, +}); -export * from '@app/translations'; +export const { trans, setLocale } = translator; diff --git a/apps/e2e/importmap.php b/apps/e2e/importmap.php index fd47adc87d8..a522562e05b 100644 --- a/apps/e2e/importmap.php +++ b/apps/e2e/importmap.php @@ -139,12 +139,6 @@ '@symfony/ux-translator' => [ 'path' => './vendor/symfony/ux-translator/assets/dist/translator_controller.js', ], - '@app/translations' => [ - 'path' => './var/translations/index.js', - ], - '@app/translations/configuration' => [ - 'path' => './var/translations/configuration.js', - ], 'typed.js' => [ 'version' => '2.1.0', ], diff --git a/apps/e2e/templates/ux_translator/basic.html.twig b/apps/e2e/templates/ux_translator/basic.html.twig index 476d2f335b6..c07aefc9572 100644 --- a/apps/e2e/templates/ux_translator/basic.html.twig +++ b/apps/e2e/templates/ux_translator/basic.html.twig @@ -11,15 +11,14 @@ {% block javascripts %} {{ parent() }}