[Translator] Refactor API to use string-based translation keys instead of generated constants

This commit is contained in:
Hugo Alliaume
2025-12-03 21:12:34 +01:00
parent 2ff4d93cf4
commit 5a161e5daa
12 changed files with 831 additions and 627 deletions

View File

@@ -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

View File

@@ -1,29 +1,41 @@
type MessageId = string;
type DomainType = string;
type LocaleType = string;
type TranslationsType = Record<DomainType, {
parameters: ParametersType;
}>;
type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
type NoParametersType = Record<string, never>;
type ParametersType = Record<string, string | number | Date> | NoParametersType;
type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType> ? Translations[D] extends {
parameters: infer Parameters;
} ? Parameters : never : never;
type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType>
? Translations[D] extends { parameters: infer Parameters }
? Parameters
: never
: never;
interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
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<LocaleType, LocaleType>): void;
declare function getLocaleFallbacks(): Record<LocaleType, LocaleType>;
declare function trans<M extends Message<TranslationsType, LocaleType>, D extends DomainsOf<M>, P extends ParametersOf<M, D>>(...args: P extends NoParametersType ? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>] : [message: M, parameters: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]): 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<MessageId, Message<TranslationsType, LocaleType>>;
declare function getDefaultLocale(): LocaleType;
declare function createTranslator<TMessages extends Messages>({ messages, locale, localeFallbacks, throwWhenNotFound, }: {
messages: TMessages;
locale?: LocaleType;
localeFallbacks?: Record<LocaleType, LocaleType>;
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<TMessage>, TParameters extends ParametersOf<TMessage, TDomain>>(id: TMessageId, parameters?: TParameters, domain?: RemoveIntlIcuSuffix<TDomain> | undefined, locale?: LocaleOf<TMessage>): 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 };

View File

@@ -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") || // <html data-symfony-ux-translator-locale="en_US">
function getDefaultLocale() {
return document.documentElement.getAttribute("data-symfony-ux-translator-locale") || // <html data-symfony-ux-translator-locale="en_US">
(document.documentElement.lang ? document.documentElement.lang.replace("-", "_") : null) || // <html lang="en-US">
"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 (typeof locale2 === "undefined" || null === locale2) {
locale2 = _locale;
}
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 formatIntl(translationsIntl[locale], parameters, locale);
}
}
const translations = message.translations[domain];
if (typeof translations !== "undefined") {
while (typeof translations[locale] === "undefined") {
locale = localesFallbacks[locale];
if (!locale) {
break;
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 (locale) {
return format(translations[locale], parameters, locale);
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
};

View File

@@ -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": {

View File

@@ -7,168 +7,176 @@
* file that was distributed with this source code.
*/
export type DomainType = string;
export type LocaleType = string;
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
export type NoParametersType = Record<string, never>;
export type ParametersType = Record<string, string | number | Date> | NoParametersType;
export type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
export type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
export type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
export type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType>
? Translations[D] extends { parameters: infer Parameters }
? Parameters
: never
: never;
export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
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<LocaleType, LocaleType> = {};
let _throwWhenNotFound = false;
export * from './types.d';
export function setLocale(locale: LocaleType | null) {
_locale = locale;
}
export function getLocale(): LocaleType {
export function getDefaultLocale(): LocaleType {
return (
_locale ||
document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // <html data-symfony-ux-translator-locale="en_US">
(document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || // <html lang="en-US">
'en'
);
}
export function throwWhenNotFound(enabled: boolean): void {
_throwWhenNotFound = enabled;
}
export function createTranslator<TMessages extends Messages>({
messages,
locale = getDefaultLocale(),
localeFallbacks = {},
throwWhenNotFound = false,
}: {
messages: TMessages;
locale?: LocaleType;
localeFallbacks?: Record<LocaleType, LocaleType>;
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<TMessage>,
TParameters extends ParametersOf<TMessage, TDomain>,
>(
id: TMessageId,
parameters?: TParameters,
domain?: RemoveIntlIcuSuffix<TDomain> | undefined,
locale?: LocaleOf<TMessage>
): string;
} {
const _messages = messages;
const _localeFallbacks = localeFallbacks;
let _locale = locale;
let _throwWhenNotFound = throwWhenNotFound;
export function setLocaleFallbacks(localeFallbacks: Record<LocaleType, LocaleType>): void {
_localeFallbacks = localeFallbacks;
}
export function getLocaleFallbacks(): Record<LocaleType, LocaleType> {
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<TranslationsType, LocaleType>,
D extends DomainsOf<M>,
P extends ParametersOf<M, D>,
>(
...args: P extends NoParametersType
? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]
: [message: M, parameters: P, domain?: RemoveIntlIcuSuffix<D>, locale?: LocaleOf<M>]
): string;
export function trans<
M extends Message<TranslationsType, LocaleType>,
D extends DomainsOf<M>,
P extends ParametersOf<M, D>,
>(
message: M,
parameters: P = {} as P,
domain: RemoveIntlIcuSuffix<DomainsOf<M>> | undefined = 'messages' as RemoveIntlIcuSuffix<DomainsOf<M>>,
locale: LocaleOf<M> | null = null
): string {
if (typeof domain === 'undefined') {
domain = 'messages' as RemoveIntlIcuSuffix<DomainsOf<M>>;
/**
* Sets the locale.
*/
function setLocale(locale: LocaleType) {
_locale = locale;
}
if (typeof locale === 'undefined' || null === locale) {
locale = getLocale() as LocaleOf<M>;
/**
* 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<TMessage>,
TParameters extends ParametersOf<TMessage, TDomain>,
>(
id: TMessageId,
parameters: TParameters = {} as TParameters,
domain: RemoveIntlIcuSuffix<TDomain> | undefined = 'messages' as RemoveIntlIcuSuffix<TDomain>,
locale: LocaleOf<TMessage> | null = null
): string {
if (typeof domain === 'undefined') {
domain = 'messages' as RemoveIntlIcuSuffix<TDomain>;
}
const translationsIntl = message.translations[`${domain}+intl-icu`];
if (typeof translationsIntl !== 'undefined') {
while (typeof translationsIntl[locale] === 'undefined') {
locale = localesFallbacks[locale] as LocaleOf<M>;
if (!locale) {
break;
if (typeof locale === 'undefined' || null === locale) {
locale = _locale as LocaleOf<TMessage>;
}
const message = _messages[id] ?? (null as TMessage | null);
if (message === null) {
return id;
}
const translationsIntl = message.translations[`${domain}+intl-icu`] ?? undefined;
if (typeof translationsIntl !== 'undefined') {
while (typeof translationsIntl[locale] === 'undefined') {
locale = _localeFallbacks[locale] as LocaleOf<TMessage>;
if (!locale) {
break;
}
}
if (locale) {
return formatIntl(translationsIntl[locale], parameters, locale);
}
}
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<TMessage>;
if (!locale) {
break;
}
}
const translations = message.translations[domain];
if (typeof translations !== 'undefined') {
while (typeof translations[locale] === 'undefined') {
locale = localesFallbacks[locale] as LocaleOf<M>;
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}".`);
}
return id;
}
if (_throwWhenNotFound) {
throw new Error(`No translation message found with id "${message.id}".`);
}
return message.id;
return {
setLocale,
getLocale,
setThrowWhenNotFound,
trans,
};
}

26
assets/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
export type MessageId = string;
export type DomainType = string;
export type LocaleType = string;
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
export type NoParametersType = Record<string, never>;
export type ParametersType = Record<string, string | number | Date> | NoParametersType;
export type RemoveIntlIcuSuffix<T> = T extends `${infer U}+intl-icu` ? U : T;
export type DomainsOf<M> = M extends Message<infer Translations, LocaleType> ? keyof Translations : never;
export type LocaleOf<M> = M extends Message<TranslationsType, infer Locale> ? Locale : never;
export type ParametersOf<M, D extends DomainType> = M extends Message<infer Translations, LocaleType>
? Translations[D] extends { parameters: infer Parameters }
? Parameters
: never
: never;
export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
translations: {
[domain in DomainType]: {
[locale in Locale]: string;
};
};
}
export type Messages = Record<MessageId, Message<TranslationsType, LocaleType>>;

View File

@@ -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 <html lang="...">, if exists
document.documentElement.lang = 'fr';
expect(getLocale()).toEqual('fr');
translator = createTranslator({ messages: {} });
expect(translator.getLocale()).toEqual('fr');
// or the locale from <html data-symfony-ux-translator-locale="...">, if exists
document.documentElement.setAttribute('data-symfony-ux-translator-locale', 'it');
expect(getLocale()).toEqual('it');
setLocale('de');
expect(getLocale()).toEqual('de');
translator = createTranslator({ messages: {} });
expect(translator.getLocale()).toEqual('it');
});
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(
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, { '%parameter1%': 'foo' }, 'messages')).toEqual(
'A message from messages catalogue with a parameter foo'
);
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, { '%parameter1%': 'foo' }, 'messages', 'en')).toEqual(
'A message from messages catalogue with a parameter foo'
);
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, { '%parameter2%': 'foo' }, 'foobar')).toEqual(
'A message from foobar catalogue with a parameter foo'
);
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, { '%parameter2%': 'foo' }, 'foobar', 'en')).toEqual(
'A message from foobar catalogue with a parameter foo'
);
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',
},
},
},
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',
},
});
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',
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'
);
});
});
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 });
const MESSAGE_FRENCH_ONLY: Message<{ messages: { parameters: NoParametersType } }, 'fr'> = {
id: 'message_french_only',
translations: {
messages: {
fr: 'Un message en français uniquement',
},
},
};
const { setLocale, getLocale, setThrowWhenNotFound, trans } = translator;
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(typeof setLocale).toBe('function');
expect(typeof getLocale).toBe('function');
expect(typeof setThrowWhenNotFound).toBe('function');
expect(typeof trans).toBe('function');
expect(trans(MESSAGE, {}, 'messages', 'fr')).toEqual('Un message en français');
expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('Un message intl en français');
expect(trans(MESSAGE, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message en français');
expect(trans(MESSAGE_INTL, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message intl en français');
expect(trans(MESSAGE, {}, 'messages', 'de_DE' as 'en')).toEqual('A message in english');
expect(trans(MESSAGE_INTL, {}, 'messages', 'de_DE' as 'en')).toEqual('A intl message in english');
expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'fr')).toEqual('Un message en français uniquement');
expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'en' as 'fr')).toEqual('message_french_only');
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');
});
});
});

View File

@@ -15,7 +15,7 @@ Installation
.. note::
This package works best with WebpackEncore. To use it with AssetMapper, see
:ref:`Using with AssetMapper <using-with-asset-mapper>`.
: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';
setLocaleFallbacks(localeFallbacks);
const translator = createTranslator({
messages,
localeFallbacks,
});
export { trans }
export * from '../var/translations';
// Allow you to use `import { trans } from './translator';` in your assets
export const { trans } = translator;
Using with WebpackEncore
~~~~~~~~~~~~~~~~~~~~~~~~
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 <https://webpack.js.org/guides/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 ``<html data-symfony-ux-translator-locale="{{ app.request.locale }}">`` attribute (e.g., ``de_AT`` or ``de`` using Symfony locale format)
#. Or with ``<html lang="{{ app.request.locale|replace({ '_': '-' }) }}">`` 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
----------------------
Q&A
---
Using this library with AssetMapper is possible, but is currently experimental
and may not be ready yet for production.
What about bundle size?
~~~~~~~~~~~~~~~~~~~~~~~
When installing with AssetMapper, Flex will add a few new items to your ``importmap.php``
file. 2 of the new items are::
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).
'@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.
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
------------------------------

View File

@@ -17,8 +17,6 @@ use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtra
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
use function Symfony\Component\String\s;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
@@ -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 @@ class TranslationsDumper
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);
export const localeFallbacks = %s;
$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)
);
}
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';
$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<LocaleType, LocaleType>;
TS
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
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 @@ class TranslationsDumper
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;
}
}

View File

@@ -35,34 +35,28 @@ class DumpEnabledLocalesTest extends KernelTestCase
self::assertStringEqualsFile(
$translationsDumpDir.'/index.js',
<<<JAVASCRIPT
export const SYMFONY_UX_GREAT = {"id":"symfony_ux.great","translations":{"messages":{"en":"Symfony UX is awesome","fr":"Symfony UX est g\u00e9nial"}}};
<<<JS
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
JAVASCRIPT
export const localeFallbacks = {"en":null,"fr":"en"};
export const messages = {
"symfony_ux.great": {"translations":{"messages":{"en":"Symfony UX is awesome","fr":"Symfony UX est g\u00e9nial"}}},
};
JS
);
self::assertStringEqualsFile(
$translationsDumpDir.'/index.d.ts',
<<<TYPESCRIPT
import { Message, NoParametersType } from '@symfony/ux-translator';
export declare const SYMFONY_UX_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
TYPESCRIPT
);
self::assertStringEqualsFile(
$translationsDumpDir.'/configuration.js',
<<<JAVASCRIPT
export const localeFallbacks = {"en":null,"fr":"en"};
JAVASCRIPT
);
self::assertStringEqualsFile(
$translationsDumpDir.'/configuration.d.ts',
<<<TYPESCRIPT
import { LocaleType } from '@symfony/ux-translator';
<<<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<LocaleType, LocaleType>;
TYPESCRIPT
export declare const messages: {
"symfony_ux.great": Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
};
TS
);
}
}

View File

@@ -52,57 +52,66 @@ class TranslationsDumperTest extends TestCase
$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 <a href=\"{link}\">this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href=\"{link}\">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"}}};
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JS'
// This file is auto-generated by the Symfony UX Translator. Do not edit it manually.
JAVASCRIPT);
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 <a href=\"{link}\">this link<\/a>","fr":"Votre article \"{title}\" a re\u00e7u un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href=\"{link}\">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"}}},
};
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TYPESCRIPT'
import { Message, NoParametersType } from '@symfony/ux-translator';
JS);
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'>;
$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';
TYPESCRIPT);
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
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()

View File

@@ -20,6 +20,7 @@ require __DIR__.'/../vendor/autoload.php';
$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'));