[Translator] Add Symfony UX Translator package

This commit is contained in:
Hugo Alliaume
2022-12-14 01:50:29 +01:00
committed by Ryan Weaver
commit c8921438ac
53 changed files with 4806 additions and 0 deletions

8
.gitattributes vendored Normal file
View File

@@ -0,0 +1,8 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.symfony.bundle.yaml export-ignore
/phpunit.xml.dist export-ignore
/assets/src export-ignore
/assets/test export-ignore
/assets/jest.config.js export-ignore
/tests export-ignore

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
vendor
composer.lock
.php_cs.cache
.phpunit.result.cache

3
.symfony.bundle.yaml Normal file
View File

@@ -0,0 +1,3 @@
branches: ["2.x"]
maintained_branches: ["2.x"]
doc_dir: "doc"

5
CHANGELOG.md Normal file
View File

@@ -0,0 +1,5 @@
# CHANGELOG
## Unreleased
- Component added

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2023-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# Symfony UX Translator
**EXPERIMENTAL** This component is currently experimental and is
likely to change, or even change drastically.
Symfony UX Translator integrates [Symfony Translation](https://symfony.com/doc/current/translation.html) for JavaScript.
**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ux to create issues or submit pull requests.
## Resources
- [Documentation](https://symfony.com/bundles/ux-translator/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)

1
assets/dist/formatters/formatter.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export declare function format(id: string, parameters: Record<string, string | number> | undefined, locale: string): string;

View File

@@ -0,0 +1 @@
export declare function formatIntl(id: string, parameters: Record<string, string | number> | undefined, locale: string): string;

26
assets/dist/translator.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
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> | 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;
};
};
}
export declare function setLocale(locale: LocaleType | null): void;
export declare function getLocale(): LocaleType;
export declare function setLocaleFallbacks(localeFallbacks: Record<LocaleType, LocaleType>): void;
export declare function getLocaleFallbacks(): Record<LocaleType, LocaleType>;
export 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;

View File

@@ -0,0 +1 @@
export * from './translator';

253
assets/dist/translator_controller.js vendored Normal file
View File

@@ -0,0 +1,253 @@
import { IntlMessageFormat } from 'intl-messageformat';
function formatIntl(id, parameters = {}, locale) {
if (id === '') {
return '';
}
const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true });
parameters = Object.assign({}, parameters);
Object.entries(parameters).forEach(([key, value]) => {
if (key.includes('%') || key.includes('{')) {
delete parameters[key];
parameters[key.replace(/[%{} ]/g, '').trim()] = value;
}
});
return intlMessage.format(parameters);
}
function strtr(string, replacePairs) {
const regex = Object.entries(replacePairs).map(([from]) => {
return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1');
});
if (regex.length === 0) {
return string;
}
return string.replace(new RegExp(regex.join('|'), 'g'), (matched) => replacePairs[matched].toString());
}
function format(id, parameters = {}, locale) {
if (null === id || '' === id) {
return '';
}
if (typeof parameters['%count%'] === 'undefined' || Number.isNaN(parameters['%count%'])) {
return strtr(id, parameters);
}
const number = Number(parameters['%count%']);
let parts = [];
if (/^\|+$/.test(id)) {
parts = id.split('|');
}
else {
const matches = id.match(/(?:\|\||[^|])+/g);
if (matches !== null) {
parts = matches;
}
}
const intervalRegex = /^(?<interval>({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?<left_delimiter>[[\]])\s*(?<left>-Inf|-?\d+(\.\d+)?)\s*,\s*(?<right>\+?Inf|-?\d+(\.\d+)?)\s*(?<right_delimiter>[[\]]))\s*(?<message>.*?)$/s;
const standardRules = [];
for (let part of parts) {
part = part.trim().replace(/\|\|/g, '|');
let matches = part.match(intervalRegex);
if (matches !== null) {
if (matches[2]) {
for (const n of matches[3].split(',')) {
if (number === Number(n)) {
return strtr(matches.groups['message'], parameters);
}
}
}
else {
const leftNumber = '-Inf' === matches.groups['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups['left']);
const rightNumber = ['Inf', '+Inf'].includes(matches.groups['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups['right']);
if (('[' === matches.groups['left_delimiter'] ? number >= leftNumber : number > leftNumber)
&& (']' === matches.groups['right_delimiter'] ? number <= rightNumber : number < rightNumber)) {
return strtr(matches.groups['message'], parameters);
}
}
}
else {
matches = part.match(/^\w+:\s*(.*?)$/);
if (matches !== null) {
standardRules.push(matches[1]);
}
else {
standardRules.push(part);
}
}
}
const position = getPluralizationRule(number, locale);
if (typeof standardRules[position] === 'undefined') {
if (1 === parts.length && typeof standardRules[0] !== 'undefined') {
return strtr(standardRules[0], parameters);
}
throw new Error(`Unable to choose a translation for "${id}" with locale "${locale}" for value "${number}". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %count% apples").`);
}
return strtr(standardRules[position], parameters);
}
function getPluralizationRule(number, locale) {
number = Math.abs(number);
let _locale = locale;
if (locale === 'pt_BR' || locale === 'en_US_POSIX') {
return 0;
}
_locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf('_')) : _locale;
switch (_locale) {
case 'af':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'en_US_POSIX':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'oc':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'ur':
case 'zu':
return (1 == number) ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'hy':
case 'ln':
case 'mg':
case 'nso':
case 'pt_BR':
case 'ti':
case 'wa':
return (number < 2) ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sh':
case 'sr':
case 'uk':
return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'cs':
case 'sk':
return (1 == number) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2);
case 'ga':
return (1 == number) ? 0 : ((2 == number) ? 1 : 2);
case 'lt':
return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'sl':
return (1 == number % 100) ? 0 : ((2 == number % 100) ? 1 : (((3 == number % 100) || (4 == number % 100)) ? 2 : 3));
case 'mk':
return (1 == number % 10) ? 0 : 1;
case 'mt':
return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3));
case 'lv':
return (0 == number) ? 0 : (((1 == number % 10) && (11 != number % 100)) ? 1 : 2);
case 'pl':
return (1 == number) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2);
case 'cy':
return (1 == number) ? 0 : ((2 == number) ? 1 : (((8 == number) || (11 == number)) ? 2 : 3));
case 'ro':
return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2);
case 'ar':
return (0 == number) ? 0 : ((1 == number) ? 1 : ((2 == number) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5))));
default:
return 0;
}
}
let _locale = null;
let _localeFallbacks = {};
function setLocale(locale) {
_locale = locale;
}
function getLocale() {
return (_locale ||
document.documentElement.getAttribute('data-symfony-ux-translator-locale') ||
document.documentElement.lang ||
'en');
}
function setLocaleFallbacks(localeFallbacks) {
_localeFallbacks = localeFallbacks;
}
function getLocaleFallbacks() {
return _localeFallbacks;
}
function trans(message, parameters = {}, domain = 'messages', locale = null) {
if (typeof domain === 'undefined') {
domain = 'messages';
}
if (typeof locale === 'undefined' || null === locale) {
locale = getLocale();
}
if (typeof message.translations === 'undefined') {
return message.id;
}
const localesFallbacks = getLocaleFallbacks();
const translationsIntl = message.translations[`${domain}+intl-icu`];
if (typeof translationsIntl !== 'undefined') {
while (typeof translationsIntl[locale] === 'undefined') {
locale = localesFallbacks[locale];
if (!locale) {
break;
}
}
if (locale) {
return formatIntl(translationsIntl[locale], parameters, locale);
}
}
const translations = message.translations[domain];
if (typeof translations !== 'undefined') {
while (typeof translations[locale] === 'undefined') {
locale = localesFallbacks[locale];
if (!locale) {
break;
}
}
if (locale) {
return format(translations[locale], parameters, locale);
}
}
return message.id;
}
export { getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, trans };

1
assets/dist/utils.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export declare function strtr(string: string, replacePairs: Record<string, string | number>): string;

1
assets/jest.config.js Normal file
View File

@@ -0,0 +1 @@
module.exports = require('../../../jest.config.js');

20
assets/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@symfony/ux-translator",
"description": "Symfony Translator for JavaScript",
"license": "MIT",
"version": "1.0.0",
"main": "dist/translator_controller.js",
"types": "dist/translator_controller.d.ts",
"peerDependencies": {
"intl-messageformat": "^10.2.5"
},
"peerDependenciesMeta": {
"intl-messageformat": {
"optional": false
}
},
"devDependencies": {
"intl-messageformat": "^10.2.5",
"ts-jest": "^27.1.5"
}
}

View File

@@ -0,0 +1,238 @@
import {strtr} from '../utils';
/**
* This code is adapted from the Symfony Translator Trait (6.2)
* @see https://github.com/symfony/symfony/blob/015d5015e353ee5448bf7c350de0db4a03f8e13a/src/Symfony/Contracts/Translation/TranslatorTrait.php
*/
/**
* Translates the given message.
*
* When a number is provided as a parameter named "%count%", the message is parsed for plural
* forms and a translation is chosen according to this number using the following rules:
*
* Given a message with different plural translations separated by a
* pipe (|), this method returns the correct portion of the message based
* on the given number, locale and the pluralization rules in the message
* itself.
*
* The message supports two different types of pluralization rules:
*
* interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples
* indexed: There is one apple|There are %count% apples
*
* The indexed solution can also contain labels (e.g. one: There is one apple).
* This is purely for making the translations more clear - it does not
* affect the functionality.
*
* The two methods can also be mixed:
* {0} There are no apples|one: There is one apple|more: There are %count% apples
*
* An interval can represent a finite set of numbers:
* {1,2,3,4}
*
* An interval can represent numbers between two numbers:
* [1, +Inf]
* ]-1,2[
*
* The left delimiter can be [ (inclusive) or ] (exclusive).
* The right delimiter can be [ (exclusive) or ] (inclusive).
* Beside numbers, you can use -Inf and +Inf for the infinite.
*
* @see https://en.wikipedia.org/wiki/ISO_31-11
*
* @private
*
* @param id The message id
* @param parameters An array of parameters for the message
* @param locale The locale
*/
export function format(id: string, parameters: Record<string, string | number> = {}, locale: string): string {
if (null === id || '' === id) {
return '';
}
if (typeof parameters['%count%'] === 'undefined' || Number.isNaN(parameters['%count%'])) {
return strtr(id, parameters);
}
const number = Number(parameters['%count%']);
let parts: Array<string> = [];
if (/^\|+$/.test(id)) {
parts = id.split('|');
} else {
const matches = id.match(/(?:\|\||[^|])+/g);
if (matches !== null) {
parts = matches;
}
}
const intervalRegex = /^(?<interval>({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?<left_delimiter>[[\]])\s*(?<left>-Inf|-?\d+(\.\d+)?)\s*,\s*(?<right>\+?Inf|-?\d+(\.\d+)?)\s*(?<right_delimiter>[[\]]))\s*(?<message>.*?)$/s;
const standardRules: Array<string> = [];
for (let part of parts) {
part = part.trim().replace(/\|\|/g, '|');
let matches = part.match(intervalRegex);
if (matches !== null) {
if (matches[2]) {
for (const n of matches[3].split(',')) {
if (number === Number(n)) {
return strtr(matches.groups!['message'], parameters);
}
}
} else {
const leftNumber = '-Inf' === matches.groups!['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups!['left']);
const rightNumber = ['Inf', '+Inf'].includes(matches.groups!['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups!['right']);
if (('[' === matches.groups!['left_delimiter'] ? number >= leftNumber : number > leftNumber)
&& (']' === matches.groups!['right_delimiter'] ? number <= rightNumber : number < rightNumber)
) {
return strtr(matches.groups!['message'], parameters);
}
}
} else {
matches = part.match(/^\w+:\s*(.*?)$/);
if (matches !== null) {
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
}
}
const position = getPluralizationRule(number, locale);
if (typeof standardRules[position] === 'undefined') {
// when there's exactly one rule given, and that rule is a standard
// rule, use this rule
if (1 === parts.length && typeof standardRules[0] !== 'undefined') {
return strtr(standardRules[0], parameters);
}
throw new Error(`Unable to choose a translation for "${id}" with locale "${locale}" for value "${number}". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %count% apples").`)
}
return strtr(standardRules[position], parameters);
}
/**
* Returns the plural position to use for the given locale and number.
*
* The plural rules are derived from code of the Zend Framework (2010-09-25),
* which is subject to the new BSD license (http://framework.zend.com/license/new-bsd).
* Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
*/
function getPluralizationRule(number: number, locale: string): number {
number = Math.abs(number);
let _locale = locale;
if (locale === 'pt_BR' || locale === 'en_US_POSIX') {
return 0;
}
_locale = _locale.length > 3 ? _locale.substring(0, _locale.indexOf('_')) : _locale;
switch (_locale) {
case 'af':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'en_US_POSIX':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'oc':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'ur':
case 'zu':
return (1 == number) ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'hy':
case 'ln':
case 'mg':
case 'nso':
case 'pt_BR':
case 'ti':
case 'wa':
return (number < 2) ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sh':
case 'sr':
case 'uk':
return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'cs':
case 'sk':
return (1 == number) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2);
case 'ga':
return (1 == number) ? 0 : ((2 == number) ? 1 : 2);
case 'lt':
return ((1 == number % 10) && (11 != number % 100)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'sl':
return (1 == number % 100) ? 0 : ((2 == number % 100) ? 1 : (((3 == number % 100) || (4 == number % 100)) ? 2 : 3));
case 'mk':
return (1 == number % 10) ? 0 : 1;
case 'mt':
return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3));
case 'lv':
return (0 == number) ? 0 : (((1 == number % 10) && (11 != number % 100)) ? 1 : 2);
case 'pl':
return (1 == number) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2);
case 'cy':
return (1 == number) ? 0 : ((2 == number) ? 1 : (((8 == number) || (11 == number)) ? 2 : 3));
case 'ro':
return (1 == number) ? 0 : (((0 == number) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2);
case 'ar':
return (0 == number) ? 0 : ((1 == number) ? 1 : ((2 == number) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5))));
default:
return 0
}
}

View File

@@ -0,0 +1,27 @@
import {IntlMessageFormat} from 'intl-messageformat';
/**
* @private
*
* @param id The message id
* @param parameters An array of parameters for the message
* @param locale The locale
*/
export function formatIntl(id: string, parameters: Record<string, string | number> = {}, locale: string): string {
if (id === '' ) {
return '';
}
const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, {ignoreTag: true});
parameters = {...parameters};
Object.entries(parameters).forEach(([key, value]) => {
if (key.includes('%') || key.includes('{')) {
delete parameters[key];
parameters[key.replace(/[%{} ]/g, '').trim()] = value;
}
});
return intlMessage.format(parameters);
}

167
assets/src/translator.ts Normal file
View File

@@ -0,0 +1,167 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
'use strict';
export type DomainType = string;
export type LocaleType = string;
export type TranslationsType = Record<DomainType, { parameters: ParametersType }>;
export type NoParametersType = Record<string, never>;
export type ParametersType = Record<string, string | number> | 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 { formatIntl } from './formatters/intl-formatter';
import { format } from './formatters/formatter';
let _locale: LocaleType | null = null;
let _localeFallbacks: Record<LocaleType, LocaleType> = {};
export function setLocale(locale: LocaleType | null) {
_locale = locale;
}
export function getLocale(): LocaleType {
return (
_locale ||
document.documentElement.getAttribute('data-symfony-ux-translator-locale') || // <html data-symfony-ux-translator-locale="en">
document.documentElement.lang || // <html lang="en">
'en'
);
}
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>>;
}
if (typeof locale === 'undefined' || null === locale) {
locale = getLocale() as LocaleOf<M>;
}
if (typeof message.translations === 'undefined') {
return message.id;
}
const localesFallbacks = getLocaleFallbacks();
const translationsIntl = message.translations[`${domain}+intl-icu`];
if (typeof translationsIntl !== 'undefined') {
while (typeof translationsIntl[locale] === 'undefined') {
locale = localesFallbacks[locale] as LocaleOf<M>;
if (!locale) {
break;
}
}
if (locale) {
return formatIntl(translationsIntl[locale], parameters, locale);
}
}
const translations = message.translations[domain];
if (typeof translations !== 'undefined') {
while (typeof translations[locale] === 'undefined') {
locale = localesFallbacks[locale] as LocaleOf<M>;
if (!locale) {
break;
}
}
if (locale) {
return format(translations[locale], parameters, locale);
}
}
return message.id;
}

View File

@@ -0,0 +1,12 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
'use strict';
export * from './translator';

19
assets/src/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* PHP strtr's equivalent, inspired and adapted from https://stackoverflow.com/a/37949642.
*
* @private
*
* @param string The string to replace in
* @param replacePairs The pairs of characters to replace
*/
export function strtr(string: string, replacePairs: Record<string, string | number>): string {
const regex: Array<string> = Object.entries(replacePairs).map(([from]) => {
return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1');
});
if (regex.length === 0) {
return string;
}
return string.replace(new RegExp(regex.join('|'), 'g'), (matched) => replacePairs[matched].toString());
}

View File

@@ -0,0 +1,149 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
'use strict';
import {format} from '../../src/formatters/formatter';
describe('Formatter', function () {
test.concurrent.each<[string, string, Record<string, string | number>]>([
['Symfony is great!', 'Symfony is great!', {}],
['Symfony is awesome!', 'Symfony is %what%!', {'%what%': 'awesome'}],
])('#%# format should returns %p', function (expected, message, parameters) {
expect(format(message, parameters, 'en')).toEqual(expected);
});
test.concurrent.each<[string, string, number]>([
['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0],
['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1],
['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10],
['There are 0 apples', 'There is 1 apple|There are %count% apples', 0],
['There is 1 apple', 'There is 1 apple|There are %count% apples', 1],
['There are 10 apples', 'There is 1 apple|There are %count% apples', 10],
// custom validation messages may be coded with a fixed value
['There are 2 apples', 'There are 2 apples', 2],
])('#%# format with choice should returns %p', function (expected, message, number) {
expect(format(message, {'%count%': number}, 'en')).toEqual(expected);
});
test.concurrent.each<[string, number, string]>([
['foo', 3, '{1,2, 3 ,4}'],
['bar', 10, '{1,2, 3 ,4}'],
['bar', 3, '[1,2]'],
['foo', 1, '[1,2]'],
['foo', 2, '[1,2]'],
['bar', 1, ']1,2['],
['bar', 2, ']1,2['],
['foo', Math.log(0), '[-Inf,2['],
['foo', -Math.log(0), '[-2,+Inf]'],
])('#%# format interval should returns %p', function (expected, number, interval) {
expect(format(interval + ' foo|[1,Inf[ bar', {'%count%': number}, 'en')).toEqual(expected);
});
test.concurrent.each<[string, number]>([
['{0} There are no apples|{1} There is one apple', 2],
['{1} There is one apple|]1,Inf] There are %count% apples', 0],
['{1} There is one apple|]2,Inf] There are %count% apples', 2],
['{0} There are no apples|There is one apple', 2],
])('#%# test non matching message', function (message, number) {
expect(() => format(message, {'%count%': number}, 'en')).toThrow(`Unable to choose a translation for "${message}" with locale "en" for value "${number}". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %count% apples").`);
})
test.concurrent.each([
['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0],
['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0],
['There are no apples', '{0}There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0],
['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1],
['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10],
['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf]There are %count% apples', 10],
['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10],
['There are 0 apples', 'There is one apple|There are %count% apples', 0],
['There is one apple', 'There is one apple|There are %count% apples', 1],
['There are 10 apples', 'There is one apple|There are %count% apples', 10],
['There are 0 apples', 'one: There is one apple|more: There are %count% apples', 0],
['There is one apple', 'one: There is one apple|more: There are %count% apples', 1],
['There are 10 apples', 'one: There is one apple|more: There are %count% apples', 10],
['There are no apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 0],
['There is one apple', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 1],
['There are 10 apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 10],
['', '{0}|{1} There is one apple|]1,Inf] There are %count% apples', 0],
['', '{0} There are no apples|{1}|]1,Inf] There are %count% apples', 1],
// Indexed only tests which are Gettext PoFile* compatible strings.
['There are 0 apples', 'There is one apple|There are %count% apples', 0],
['There is one apple', 'There is one apple|There are %count% apples', 1],
['There are 2 apples', 'There is one apple|There are %count% apples', 2],
// Tests for float numbers
['There is almost one apple', '{0} There are no apples|]0,1[ There is almost one apple|{1} There is one apple|[1,Inf] There is more than one apple', 0.7],
['There is one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1],
['There is more than one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1.7],
['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0],
['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0.0],
['There are no apples', '{0.0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0],
// Test texts with new-lines
// with double-quotes and \n in id & double-quotes and actual newlines in text
['This is a text with a\n new-line in it. Selector = 0.', `{0}This is a text with a
new-line in it. Selector = 0.|{1}This is a text with a
new-line in it. Selector = 1.|[1,Inf]This is a text with a
new-line in it. Selector > 1.`, 0],
// with double-quotes and \n in id and single-quotes and actual newlines in text
['This is a text with a\n new-line in it. Selector = 1.', `{0}This is a text with a
new-line in it. Selector = 0.|{1}This is a text with a
new-line in it. Selector = 1.|[1,Inf]This is a text with a
new-line in it. Selector > 1.`, 1],
['This is a text with a\n new-line in it. Selector > 1.', `{0}This is a text with a
new-line in it. Selector = 0.|{1}This is a text with a
new-line in it. Selector = 1.|[1,Inf]This is a text with a
new-line in it. Selector > 1.`, 5],
// with double-quotes and id split accros lines
[`This is a text with a
new-line in it. Selector = 1.`, `{0}This is a text with a
new-line in it. Selector = 0.|{1}This is a text with a
new-line in it. Selector = 1.|[1,Inf]This is a text with a
new-line in it. Selector > 1.`, 1],
// with single-quotes and id split accros lines
[`This is a text with a
new-line in it. Selector > 1.`, `{0}This is a text with a
new-line in it. Selector = 0.|{1}This is a text with a
new-line in it. Selector = 1.|[1,Inf]This is a text with a
new-line in it. Selector > 1.`, 5],
// with single-quotes and \n in text
['This is a text with a\nnew-line in it. Selector = 0.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 0],
// with double-quotes and id split accros lines
['This is a text with a\nnew-line in it. Selector = 1.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 1],
// esacape pipe
['This is a text with | in it. Selector = 0.', '{0}This is a text with || in it. Selector = 0.|{1}This is a text with || in it. Selector = 1.', 0],
// Empty plural set (2 plural forms) from a .PO file
['', '|', 1],
// Empty plural set (3 plural forms) from a .PO file
['', '||', 1],
// Floating values
['1.5 liters', '%count% liter|%count% liters', 1.5],
['1.5 litre', '%count% litre|%count% litres', 1.5, 'fr'],
// Negative values
['-1 degree', '%count% degree|%count% degrees', -1],
['-1 degré', '%count% degré|%count% degrés', -1],
['-1.5 degrees', '%count% degree|%count% degrees', -1.5],
['-1.5 degré', '%count% degré|%count% degrés', -1.5, 'fr'],
['-2 degrees', '%count% degree|%count% degrees', -2],
['-2 degrés', '%count% degré|%count% degrés', -2],
])('#%# test choose', (expected, id, number, locale = 'en') => {
expect(format(id, {'%count%': number}, locale)).toBe(expected);
})
});

View File

@@ -0,0 +1,64 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
'use strict';
import {formatIntl} from '../../src/formatters/intl-formatter';
describe('Intl Formatter', function () {
test('format with named arguments', function() {
const chooseMessage = `
{gender_of_host, select,
female {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to her party.}
=2 {{host} invites {guest} and one other person to her party.}
other {{host} invites {guest} as one of the # people invited to her party.}}}
male {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to his party.}
=2 {{host} invites {guest} and one other person to his party.}
other {{host} invites {guest} as one of the # people invited to his party.}}}
other {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to their party.}
=2 {{host} invites {guest} and one other person to their party.}
other {{host} invites {guest} as one of the # people invited to their party.}}}}`.trim();
const message = formatIntl(chooseMessage, {
gender_of_host: 'male',
num_guests: 10,
host: 'Fabien',
guest: 'Guilherme',
}, 'en');
expect(message).toEqual('Fabien invites Guilherme as one of the 9 people invited to his party.');
})
test('percents and brackets are trimmed', function() {
expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en')).toEqual('Hello Fab');
expect(formatIntl('Hello {name}', { '%name%': 'Fab'}, 'en')).toEqual('Hello Fab');
expect(formatIntl('Hello {name}', { '{{ name }}': 'Fab'}, 'en')).toEqual('Hello Fab');
// Parameters object should not be modified
const parameters = { '%name%': 'Fab' };
expect(formatIntl('Hello {name}', parameters, 'en')).toEqual('Hello Fab');
expect(parameters).toEqual({ '%name%': 'Fab' });
});
test('format with HTML inside', function() {
expect(formatIntl('Hello <b>{name}</b>', { name: 'Fab'}, 'en')).toEqual('Hello <b>Fab</b>');
expect(formatIntl('Hello {name}', { name: '<b>Fab</b>'}, 'en')).toEqual('Hello <b>Fab</b>');
})
test('format with locale containg underscore', function() {
expect(formatIntl('Hello {name}', { name: 'Fab'}, 'en_US')).toEqual('Hello Fab');
expect(formatIntl('Bonjour {name}', { name: 'Fab'}, 'fr_FR')).toEqual('Bonjour Fab');
});
});

View File

@@ -0,0 +1,374 @@
import {getLocale, Message, NoParametersType, setLocale, setLocaleFallbacks, trans} from '../src/translator';
describe('Translator', function () {
beforeEach(function() {
setLocale(null);
setLocaleFallbacks({})
document.documentElement.lang = '';
document.documentElement.removeAttribute('data-symfony-ux-translator-locale');
})
describe('getLocale', function () {
test('default locale', function () {
// 'en' is the default locale
expect(getLocale()).toEqual('en');
// or the locale from <html lang="...">, if exists
document.documentElement.lang = 'fr';
expect(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');
});
});
describe('setLocale', function () {
test('custom locale', function () {
setLocale('fr');
expect(getLocale()).toEqual('fr');
});
});
describe('trans', function () {
test('basic message', function () {
const MESSAGE_BASIC: Message<{ messages: { parameters: NoParametersType } }, 'en'> = {
id: 'message.basic',
translations: {
messages: {
en: 'A basic message',
}
}
};
expect(trans(MESSAGE_BASIC)).toEqual('A basic message')
expect(trans(MESSAGE_BASIC, {})).toEqual('A basic message')
expect(trans(MESSAGE_BASIC, {}, 'messages')).toEqual('A basic message')
expect(trans(MESSAGE_BASIC, {}, 'messages', 'en')).toEqual('A basic message')
// @ts-expect-error "%count%" is not a valid parameter
expect(trans(MESSAGE_BASIC, {'%count%': 1})).toEqual('A basic message')
// @ts-expect-error "foo" is not a valid domain
expect(trans(MESSAGE_BASIC, {}, 'foo')).toEqual('message.basic');
// @ts-expect-error "fr" is not a valid locale
expect(trans(MESSAGE_BASIC, {}, 'messages', 'fr')).toEqual('message.basic');
});
test('basic message with parameters', function () {
const MESSAGE_BASIC_WITH_PARAMETERS: Message<{ messages: { parameters: { '%parameter1%': string, '%parameter2%': string } } }, 'en'> = {
id: 'message.basic.with.parameters',
translations: {
messages: {
en: 'A basic message %parameter1% %parameter2%',
}
}
};
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {
'%parameter1%': 'foo',
'%parameter2%': 'bar'
})).toEqual('A basic message foo bar');
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {
'%parameter1%': 'foo',
'%parameter2%': 'bar'
}, 'messages')).toEqual('A basic message foo bar');
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {
'%parameter1%': 'foo',
'%parameter2%': 'bar'
}, 'messages', 'en')).toEqual('A basic message foo bar');
// @ts-expect-error Parameters "%parameter1%" and "%parameter2%" are missing
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {})).toEqual('A basic message %parameter1% %parameter2%');
// @ts-expect-error Parameter "%parameter2%" is missing
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {'%parameter1%': 'foo'})).toEqual('A basic message foo %parameter2%');
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {
'%parameter1%': 'foo',
'%parameter2%': 'bar'
// @ts-expect-error "foobar" is not a valid domain
}, 'foobar')).toEqual('message.basic.with.parameters');
expect(trans(MESSAGE_BASIC_WITH_PARAMETERS, {
'%parameter1%': 'foo',
'%parameter2%': 'bar'
// @ts-expect-error "fr" is not a valid locale
}, 'messages', 'fr')).toEqual('message.basic.with.parameters');
});
test('intl message', function () {
const MESSAGE_INTL: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'> = {
id: 'message.intl',
translations: {
'messages+intl-icu': {
en: 'An intl message',
}
},
};
expect(trans(MESSAGE_INTL)).toEqual('An intl message');
expect(trans(MESSAGE_INTL, {})).toEqual('An intl message');
expect(trans(MESSAGE_INTL, {}, 'messages')).toEqual('An intl message');
expect(trans(MESSAGE_INTL, {}, 'messages', 'en')).toEqual('An intl message');
// @ts-expect-error "%count%" is not a valid parameter
expect(trans(MESSAGE_INTL, {'%count%': 1})).toEqual('An intl message');
// @ts-expect-error "foo" is not a valid domain
expect(trans(MESSAGE_INTL, {}, 'foo')).toEqual('message.intl');
// @ts-expect-error "fr" is not a valid locale
expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('message.intl');
});
test('intl message with parameters', function () {
const INTL_MESSAGE_WITH_PARAMETERS: Message<{
'messages+intl-icu': {
parameters: {
gender_of_host: 'male' | 'female' | string,
num_guests: number,
host: string,
guest: string,
}
}
}, 'en'> = {
id: 'message.intl.with.parameters',
translations: {
'messages+intl-icu': {
en: `
{gender_of_host, select,
female {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to her party.}
=2 {{host} invites {guest} and one other person to her party.}
other {{host} invites {guest} as one of the # people invited to her party.}}}
male {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to his party.}
=2 {{host} invites {guest} and one other person to his party.}
other {{host} invites {guest} as one of the # people invited to his party.}}}
other {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to their party.}
=2 {{host} invites {guest} and one other person to their party.}
other {{host} invites {guest} as one of the # people invited to their party.}}}}`.trim(),
}
},
};
expect(trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
num_guests: 123,
host: 'John',
guest: 'Mary',
})).toEqual('John invites Mary as one of the 122 people invited to his party.');
expect(trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'female',
num_guests: 44,
host: 'Mary',
guest: 'John',
}, 'messages')).toEqual('Mary invites John as one of the 43 people invited to her party.');
expect(trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'female',
num_guests: 1,
host: 'Lola',
guest: 'Hugo',
}, 'messages', 'en')).toEqual('Lola invites Hugo to her party.');
expect(function () {
// @ts-expect-error Parameters "gender_of_host", "num_guests", "host", and "guest" are missing
trans(INTL_MESSAGE_WITH_PARAMETERS, {});
}).toThrow(/^The intl string context variable "gender_of_host" was not provided/);
expect(function () {
// @ts-expect-error Parameters "num_guests", "host", and "guest" are missing
trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
});
}).toThrow(/^The intl string context variable "num_guests" was not provided/);
expect(function () {
// @ts-expect-error Parameters "host", and "guest" are missing
trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
num_guests: 123,
})
}).toThrow(/^The intl string context variable "host" was not provided/);
expect(function () {
// @ts-expect-error Parameter "guest" is missing
trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
num_guests: 123,
host: 'John',
})
}).toThrow(/^The intl string context variable "guest" was not provided/);
expect(
trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
num_guests: 123,
host: 'John',
guest: 'Mary',
},
// @ts-expect-error Domain "foobar" is invalid
'foobar'
)).toEqual('message.intl.with.parameters');
expect(
trans(INTL_MESSAGE_WITH_PARAMETERS, {
gender_of_host: 'male',
num_guests: 123,
host: 'John',
guest: 'Mary',
},
'messages',
// @ts-expect-error Locale "fr" is invalid
'fr'
)).toEqual('message.intl.with.parameters');
});
test('same message id for multiple domains', function () {
const MESSAGE_MULTI_DOMAINS: Message<{ foobar: { parameters: NoParametersType }, messages: { parameters: NoParametersType } }, 'en'> = {
id: 'message.multi_domains',
translations: {
foobar: {
en: 'A message from foobar catalogue',
},
messages: {
en: 'A message from messages catalogue',
}
}
};
expect(trans(MESSAGE_MULTI_DOMAINS)).toEqual('A message from messages catalogue');
expect(trans(MESSAGE_MULTI_DOMAINS, {})).toEqual('A message from messages catalogue');
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages')).toEqual('A message from messages catalogue');
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar')).toEqual('A message from foobar catalogue');
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages', 'en')).toEqual('A message from messages catalogue');
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar', 'en')).toEqual('A message from foobar catalogue');
// @ts-expect-error Domain "acme" is invalid
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'acme', 'fr')).toEqual('message.multi_domains');
// @ts-expect-error Locale "fr" is invalid
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'messages', 'fr')).toEqual('message.multi_domains');
// @ts-expect-error Locale "fr" is invalid
expect(trans(MESSAGE_MULTI_DOMAINS, {}, 'foobar', 'fr')).toEqual('message.multi_domains');
});
test('same message id for multiple domains, and different parameters', function () {
const MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS: Message<{ foobar: { parameters: { '%parameter2%': string } }, messages: { parameters: { '%parameter1%': string } } }, 'en'> = {
id: 'message.multi_domains.different_parameters',
translations: {
foobar: {
en: 'A message from foobar catalogue with a parameter %parameter2%',
},
messages: {
en: 'A message from messages catalogue with a parameter %parameter1%',
}
}
};
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'})).toEqual('A message from messages catalogue with a parameter foo');
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages')).toEqual('A message from messages catalogue with a parameter foo');
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages', 'en')).toEqual('A message from messages catalogue with a parameter foo');
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter2%': 'foo'}, 'foobar')).toEqual('A message from foobar catalogue with a parameter foo');
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter2%': 'foo'}, 'foobar', 'en')).toEqual('A message from foobar catalogue with a parameter foo');
// @ts-expect-error Parameter "%parameter1%" is missing
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {})).toEqual('A message from messages catalogue with a parameter %parameter1%');
// @ts-expect-error Domain "baz" is invalid
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'baz')).toEqual('message.multi_domains.different_parameters');
// @ts-expect-error Locale "fr" is invalid
expect(trans(MESSAGE_MULTI_DOMAINS_WITH_PARAMETERS, {'%parameter1%': 'foo'}, 'messages', 'fr')).toEqual('message.multi_domains.different_parameters');
});
test('message from intl domain should be prioritized over its non-intl equivalent', function () {
const MESSAGE: Message<{ 'messages+intl-icu': { parameters: NoParametersType }, messages: { parameters: NoParametersType } }, 'en'> = {
id: 'message',
translations: {
'messages+intl-icu': {
en: 'A intl message',
},
messages: {
en: 'A basic message',
}
}
}
expect(trans(MESSAGE)).toEqual('A intl message');
expect(trans(MESSAGE, {})).toEqual('A intl message');
expect(trans(MESSAGE, {}, 'messages')).toEqual('A intl message');
expect(trans(MESSAGE, {}, 'messages', 'en')).toEqual('A intl message');
});
test('fallback behavior', function() {
setLocaleFallbacks({'fr_FR':'fr','fr':'en','en_US':'en','en_GB':'en','de_DE':'de','de':'en'});
const MESSAGE: Message<{ messages: { parameters: NoParametersType } }, 'en'|'en_US'|'fr'> = {
id: 'message',
translations: {
messages: {
en: 'A message in english',
en_US: 'A message in english (US)',
fr: 'Un message en français',
}
}
}
const MESSAGE_INTL: Message<{ messages: { parameters: NoParametersType } }, 'en'|'en_US'|'fr'> = {
id: 'message_intl',
translations: {
messages: {
en: 'A intl message in english',
en_US: 'A intl message in english (US)',
fr: 'Un message intl en français',
}
}
}
const MESSAGE_FRENCH_ONLY: Message<{ messages: { parameters: NoParametersType } }, 'fr'> = {
id: 'message_french_only',
translations: {
messages: {
fr: 'Un message en français uniquement',
}
}
}
expect(trans(MESSAGE, {}, 'messages', 'en')).toEqual('A message in english');
expect(trans(MESSAGE_INTL, {}, 'messages', 'en')).toEqual('A intl message in english');
expect(trans(MESSAGE, {}, 'messages', 'en_US')).toEqual('A message in english (US)');
expect(trans(MESSAGE_INTL, {}, 'messages', 'en_US')).toEqual('A intl message in english (US)');
expect(trans(MESSAGE, {}, 'messages', 'en_GB' as 'en')).toEqual('A message in english');
expect(trans(MESSAGE_INTL, {}, 'messages', 'en_GB' as 'en')).toEqual('A intl message in english');
expect(trans(MESSAGE, {}, 'messages', 'fr')).toEqual('Un message en français');
expect(trans(MESSAGE_INTL, {}, 'messages', 'fr')).toEqual('Un message intl en français');
expect(trans(MESSAGE, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message en français');
expect(trans(MESSAGE_INTL, {}, 'messages', 'fr_FR' as 'fr')).toEqual('Un message intl en français');
expect(trans(MESSAGE, {}, 'messages', 'de_DE' as 'en')).toEqual('A message in english');
expect(trans(MESSAGE_INTL, {}, 'messages', 'de_DE' as 'en')).toEqual('A intl message in english');
expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'fr')).toEqual('Un message en français uniquement');
expect(trans(MESSAGE_FRENCH_ONLY, {}, 'messages', 'en' as 'fr')).toEqual('message_french_only');
})
});
});

35
assets/test/utils.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import {strtr} from '../src/utils';
describe('Utils', function () {
test.concurrent.each<[string, string, Record<string, string>]>([
// https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_basic.phpt
['TEST STrTr', 'test strtr', {'t': 'T', 'e': 'E', 'st': 'ST'}],
['TEST STrTr', 'test strtr', {'t': 'T', 'e': 'E', 'st': 'ST'}],
// https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_variation1.phpt
['a23', '123', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}],
['1bc', 'abc', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}],
['a1b2c3', '1a2b3c', {'1': 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}],
[`
a23
1bc
a1b2c3`, `
123
abc
1a2b3c`, {1: 'a', 'a': '1', '2b3c': 'b2c3', 'b2c3': '3c2b'}],
// https://github.com/php/php-src/blob/master/ext/standard/tests/strings/strtr_variation2.phpt
['$', '%', {'$': '%', '%': '$', '#*&@()': '()@&*#'}],
['#%*', '#$*', {'$': '%', '%': '$', '#*&@()': '()@&*#'}],
['text & @()', 'text & @()', {'$': '%', '%': '$', '#*&@()': '()@&*#'}],
[`
$
#%*&
text & @()`, `
%
#$*&
text & @()`, {'$': '%', '%': '$', '#*&@()': '()@&*#'}],
])('strtr', function (expected, string, replacePairs) {
expect(strtr(string, replacePairs)).toEqual(expected);
});
});

49
composer.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "symfony/ux-translator",
"type": "symfony-bundle",
"description": "Symfony Translator for JavaScript",
"keywords": [
"symfony-ux"
],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Hugo Alliaume",
"email": "hugo@alliau.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"autoload": {
"psr-4": {
"Symfony\\UX\\Translator\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\UX\\Translator\\Tests\\": "tests/"
}
},
"require": {
"php": ">=8.0",
"symfony/console": "^5.4|^6.0",
"symfony/filesystem": "^5.4|^6.0",
"symfony/string": "^5.4|^6.0",
"symfony/translation": "^5.4|^6.0"
},
"require-dev": {
"symfony/framework-bundle": "^5.4|^6.0",
"symfony/phpunit-bridge": "^5.2|^6.0",
"symfony/var-dumper": "^5.4|^6.0"
},
"extra": {
"thanks": {
"name": "symfony/ux",
"url": "https://github.com/symfony/ux"
}
},
"minimum-stability": "dev"
}

47
config/services.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer;
use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
use Symfony\UX\Translator\TranslationsDumper;
/*
* @author Hugo Alliaume <hugo@alliau.me>
*/
return static function (ContainerConfigurator $container): void {
$container->services()
->set('ux.translator.cache_warmer.translations_cache_warmer', TranslationsCacheWarmer::class)
->args([
service('translator'),
service('ux.translator.translations_dumper'),
])
->tag('kernel.cache_warmer')
->set('ux.translator.translations_dumper', TranslationsDumper::class)
->args([
null, // Dump directory
service('ux.translator.message_parameters.extractor.message_parameters_extractor'),
service('ux.translator.message_parameters.extractor.intl_message_parameters_extractor'),
service('ux.translator.message_parameters.printer.typescript_message_parameters_printer'),
service('filesystem'),
])
->set('ux.translator.message_parameters.extractor.message_parameters_extractor', MessageParametersExtractor::class)
->set('ux.translator.message_parameters.extractor.intl_message_parameters_extractor', IntlMessageParametersExtractor::class)
->set('ux.translator.message_parameters.printer.typescript_message_parameters_printer', TypeScriptMessageParametersPrinter::class)
;
};

118
doc/index.rst Normal file
View File

@@ -0,0 +1,118 @@
Symfony UX Translator
=====================
Symfony UX Translator is a Symfony bundle providing the same mechanism as `Symfony Translator`_
in JavaScript with a TypeScript integration, in Symfony applications. It is part of `the Symfony UX initiative`_.
The `ICU Message Format`_ is also supported.
Installation
------------
Before you start, make sure you have `Symfony UX configured in your app`_.
Install this bundle using Composer and Symfony Flex:
.. code-block:: terminal
$ composer require symfony/ux-translator
# Don't forget to install the JavaScript dependencies as well and compile
$ npm install --force
$ npm run watch
# or use yarn
$ yarn install --force
$ yarn watch
After installing the bundle, the following file should be created, thanks to the Symfony Flex recipe:
.. code-block:: javascript
// assets/translator.js
/*
* This file is part of the Symfony UX Translator package.
*
* If folder "../var/translations" does not exist, or some translations are missing,
* you must warmup your Symfony cache to refresh JavaScript translations.
*
* If you use TypeScript, you can rename this file to "translator.ts" to take advantage of types checking.
*/
import { trans, getLocale, setLocale, setLocaleFallbacks } from '@symfony/ux-translator';
import { localeFallbacks } from '../var/translations/configuration';
setLocaleFallbacks(localeFallbacks);
export { trans }
export * from '../var/translations';
Usage
-----
When warming up the Symfony cache, all of your translations will be dumped as JavaScript into the ``var/translations/`` directory.
For a better developer experience, TypeScript types definitions are also generated aside those JavaScript files.
Then, you will be able to import those JavaScript translations in your assets.
Don't worry about your final bundle size, only the translations you use will be included in your final bundle, thanks to the [tree shaking](https://webpack.js.org/guides/tree-shaking/).
Configuring the default locale
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
By default, the default locale is ``en`` (English) that you can configure through many ways (in order of priority):
1. With ``setLocale('your-locale')`` from ``@symfony/ux-translator`` package
2. Or with ``<html data-symfony-ux-translator-locale="your-locale">`` attribute
3. Or with ``<html lang="your-locale">`` attribute
Importing and using translations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you use the Symfony Flex recipe, you can import the ``trans()`` function and your translations in your assets from the file `assets/translator.js`.
Translations are available as named exports, by using the translation's id transformed in uppercase snake-case (e.g.: `my.translation` becomes `MY_TRANSLATION`),
so you can import them like this:
.. code-block:: javascript
// assets/my_file.js
import {
trans,
TRANSLATION_SIMPLE,
TRANSLATION_WITH_PARAMETERS,
TRANSLATION_MULTI_DOMAINS,
TRANSLATION_MULTI_LOCALES,
} from './translator';
// No parameters, uses the default domain ("messages") and the default locale
trans(TRANSLATION_SIMPLE);
// Two parameters "count" and "foo", uses the default domain ("messages") and the default locale
trans(TRANSLATION_WITH_PARAMETERS, { count: 123, foo: 'bar' });
// No parameters, uses the default domain ("messages") and the default locale
trans(TRANSLATION_MULTI_DOMAINS);
// Same as above, but uses the "domain2" domain
trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain2');
// Same as above, but uses the "domain3" domain
trans(TRANSLATION_MULTI_DOMAINS, {}, 'domain3');
// No parameters, uses the default domain ("messages") and the default locale
trans(TRANSLATION_MULTI_LOCALES);
// Same as above, but uses the "fr" locale
trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'fr');
// Same as above, but uses the "it" locale
trans(TRANSLATION_MULTI_LOCALES, {}, 'messages', 'it');
Backward Compatibility promise
------------------------------
This bundle aims at following the same Backward Compatibility promise as
the Symfony framework:
https://symfony.com/doc/current/contributing/code/bc.html
.. _`Symfony Translator`: https://symfony.com/doc/current/translation.html
.. _`the Symfony UX initiative`: https://symfony.com/ux
.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html
.. _`ICU Message Format`: https://symfony.com/doc/current/translation/message_format.html

30
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.1/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true">
<php>
<ini name="error_reporting" value="-1" />
<ini name="intl.default_locale" value="en" />
<ini name="intl.error_level" value="0" />
<ini name="memory_limit" value="-1" />
<env name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\CacheWarmer;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\Translation\TranslatorBagInterface;
use Symfony\UX\Translator\TranslationsDumper;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
class TranslationsCacheWarmer implements CacheWarmerInterface
{
public function __construct(
private TranslatorBagInterface $translatorBag,
private TranslationsDumper $translationsDumper,
) {
}
public function isOptional(): bool
{
return true;
}
public function warmUp(string $cacheDir): array
{
$this->translationsDumper->dump(
...$this->translatorBag->getCatalogues()
);
// No need to preload anything
return [];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Symfony\UX\Translator\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('ux_translator');
$rootNode = $treeBuilder->getRootNode();
$rootNode
->children()
->scalarNode('dump_directory')->defaultValue('%kernel.project_dir%/var/translations')->end()
->end()
;
return $treeBuilder;
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @internal
*
* @experimental
*/
class TranslatorExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config')));
$loader->load('services.php');
$container->getDefinition('ux.translator.translations_dumper')->setArgument(0, $config['dump_directory']);
}
}

92
src/Intl/ErrorKind.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace Symfony\UX\Translator\Intl;
/**
* Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/error.ts#L9-L77.
*
* @experimental
*/
final class ErrorKind
{
/** Argument is unclosed (e.g. `{0`) */
public const EXPECT_ARGUMENT_CLOSING_BRACE = 'EXPECT_ARGUMENT_CLOSING_BRACE';
/** Argument is empty (e.g. `{}`). */
public const EMPTY_ARGUMENT = 'EMPTY_ARGUMENT';
/** Argument is malformed (e.g. `{foo!}``) */
public const MALFORMED_ARGUMENT = 'MALFORMED_ARGUMENT';
/** Expect an argument type (e.g. `{foo,}`) */
public const EXPECT_ARGUMENT_TYPE = 'EXPECT_ARGUMENT_TYPE';
/** Unsupported argument type (e.g. `{foo,foo}`) */
public const INVALID_ARGUMENT_TYPE = 'INVALID_ARGUMENT_TYPE';
/** Expect an argument style (e.g. `{foo, number, }`) */
public const EXPECT_ARGUMENT_STYLE = 'EXPECT_ARGUMENT_STYLE';
/** The number skeleton is invalid. */
public const INVALID_NUMBER_SKELETON = 'INVALID_NUMBER_SKELETON';
/** The date time skeleton is invalid. */
public const INVALID_DATE_TIME_SKELETON = 'INVALID_DATE_TIME_SKELETON';
/** Exepct a number skeleton following the `::` (e.g. `{foo, number, ::}`) */
public const EXPECT_NUMBER_SKELETON = 'EXPECT_NUMBER_SKELETON';
/** Exepct a date time skeleton following the `::` (e.g. `{foo, date, ::}`) */
public const EXPECT_DATE_TIME_SKELETON = 'EXPECT_DATE_TIME_SKELETON';
/** Unmatched apostrophes in the argument style (e.g. `{foo, number, 'test`) */
public const UNCLOSED_QUOTE_IN_ARGUMENT_STYLE = 'UNCLOSED_QUOTE_IN_ARGUMENT_STYLE';
/** Missing select argument options (e.g. `{foo, select}`) */
public const EXPECT_SELECT_ARGUMENT_OPTIONS = 'EXPECT_SELECT_ARGUMENT_OPTIONS';
/** Expecting an offset value in `plural` or `selectordinal` argument (e.g `{foo, plural, offset}`) */
public const EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE = 'EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE';
/** Offset value in `plural` or `selectordinal` is invalid (e.g. `{foo, plural, offset: x}`) */
public const INVALID_PLURAL_ARGUMENT_OFFSET_VALUE = 'INVALID_PLURAL_ARGUMENT_OFFSET_VALUE';
/** Expecting a selector in `select` argument (e.g `{foo, select}`) */
public const EXPECT_SELECT_ARGUMENT_SELECTOR = 'EXPECT_SELECT_ARGUMENT_SELECTOR';
/** Expecting a selector in `plural` or `selectordinal` argument (e.g `{foo, plural}`) */
public const EXPECT_PLURAL_ARGUMENT_SELECTOR = 'EXPECT_PLURAL_ARGUMENT_SELECTOR';
/** Expecting a message fragment after the `select` selector (e.g. `{foo, select, apple}`) */
public const EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT = 'EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT';
/**
* Expecting a message fragment after the `plural` or `selectordinal` selector
* (e.g. `{foo, plural, one}`).
*/
public const EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT = 'EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT';
/** Selector in `plural` or `selectordinal` is malformed (e.g. `{foo, plural, =x {#}}`) */
public const INVALID_PLURAL_ARGUMENT_SELECTOR = 'INVALID_PLURAL_ARGUMENT_SELECTOR';
/**
* Duplicate selectors in `plural` or `selectordinal` argument.
* (e.g. {foo, plural, one {#} one {#}}).
*/
public const DUPLICATE_PLURAL_ARGUMENT_SELECTOR = 'DUPLICATE_PLURAL_ARGUMENT_SELECTOR';
/** Duplicate selectors in `select` argument.
* (e.g. {foo, select, apple {apple} apple {apple}}).
*/
public const DUPLICATE_SELECT_ARGUMENT_SELECTOR = 'DUPLICATE_SELECT_ARGUMENT_SELECTOR';
/** Plural or select argument option must have `other` clause. */
public const MISSING_OTHER_CLAUSE = 'MISSING_OTHER_CLAUSE';
/** The tag is malformed. (e.g. `<bold!>foo</bold!>) */
public const INVALID_TAG = 'INVALID_TAG';
/** The tag name is invalid. (e.g. `<123>foo</123>`) */
public const INVALID_TAG_NAME = 'INVALID_TAG_NAME';
/** The closing tag does not match the opening tag. (e.g. `<bold>foo</italic>`) */
public const UNMATCHED_CLOSING_TAG = 'UNMATCHED_CLOSING_TAG';
/** The opening tag has unmatched closing tag. (e.g. `<bold>foo`) */
public const UNCLOSED_TAG = 'UNCLOSED_TAG';
private function __construct()
{
}
}

File diff suppressed because it is too large Load Diff

20
src/Intl/Location.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Symfony\UX\Translator\Intl;
/**
* Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L58-L61.
*
* @experimental
*/
final class Location
{
public Position $start;
public Position $end;
public function __construct(Position $start, Position $end)
{
$this->start = $start;
$this->end = $end;
}
}

24
src/Intl/Position.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace Symfony\UX\Translator\Intl;
/**
* Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L53-L57.
*
* @experimental
*/
final class Position
{
/** Offset in terms of UTF-16 *code unit*. */
public int $offset;
public int $line;
/** Column offset in terms of unicode *code point*. */
public int $column;
public function __construct(int $offset, int $line, int $column)
{
$this->offset = $offset;
$this->line = $line;
$this->column = $column;
}
}

18
src/Intl/SkeletonType.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace Symfony\UX\Translator\Intl;
/**
* Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L48-L51.
*
* @experimental
*/
final class SkeletonType
{
public const NUMBER = 'number';
public const DATE_TIME = 'dateTime';
private function __construct()
{
}
}

61
src/Intl/Type.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace Symfony\UX\Translator\Intl;
/**
* Adapted from https://github.com/formatjs/formatjs/blob/590f1f81b26934c6dc7a55fff938df5436c6f158/packages/icu-messageformat-parser/types.ts#L8-L46.
*
* @experimental
*/
final class Type
{
/**
* Raw text.
*/
public const LITERAL = 'literal';
/**
* Variable w/o any format, e.g `var` in `this is a {var}`.
*/
public const ARGUMENT = 'argument';
/**
* Variable w/ number format.
*/
public const NUMBER = 'number';
/**
* Variable w/ date format.
*/
public const DATE = 'date';
/**
* Variable w/ time format.
*/
public const TIME = 'time';
/**
* Variable w/ select format.
*/
public const SELECT = 'select';
/**
* Variable w/ plural format.
*/
public const PLURAL = 'plural';
/**
* Only possible within plural argument.
* This is the `#` symbol that will be substituted with the count.
*/
public const POUND = 'pound';
/**
* XML-like tag.
*/
public const TAG = 'tag';
private function __construct()
{
}
}

370
src/Intl/Utils.php Normal file
View File

@@ -0,0 +1,370 @@
<?php
namespace Symfony\UX\Translator\Intl;
use function Symfony\Component\String\s;
/**
* @experimental
*/
final class Utils
{
private function __construct()
{
}
/**
* This check if codepoint is alphabet (lower & uppercase).
*/
public static function isAlpha(int $codepoint): bool
{
return
($codepoint >= 97 && $codepoint <= 122) ||
($codepoint >= 65 && $codepoint <= 90)
;
}
public static function isAlphaOrSlash(int $codepoint): bool
{
return self::isAlpha($codepoint) || 47 === $codepoint; /* '/' */
}
/** See `parseTag` function docs. */
public static function isPotentialElementNameChar(int $c): bool
{
return
45 === $c /* '-' */ ||
46 === $c /* '.' */ ||
($c >= 48 && $c <= 57) /* 0..9 */ ||
95 === $c /* '_' */ ||
($c >= 97 && $c <= 122) /* a..z */ ||
($c >= 65 && $c <= 90) /* A..Z */ ||
0xB7 == $c ||
($c >= 0xC0 && $c <= 0xD6) ||
($c >= 0xD8 && $c <= 0xF6) ||
($c >= 0xF8 && $c <= 0x37D) ||
($c >= 0x37F && $c <= 0x1FFF) ||
($c >= 0x200C && $c <= 0x200D) ||
($c >= 0x203F && $c <= 0x2040) ||
($c >= 0x2070 && $c <= 0x218F) ||
($c >= 0x2C00 && $c <= 0x2FEF) ||
($c >= 0x3001 && $c <= 0xD7FF) ||
($c >= 0xF900 && $c <= 0xFDCF) ||
($c >= 0xFDF0 && $c <= 0xFFFD) ||
($c >= 0x10000 && $c <= 0xEFFFF)
;
}
/**
* Code point equivalent of regex `\p{White_Space}`.
* From: https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt.
*/
public static function isWhiteSpace(int $c)
{
return
($c >= 0x0009 && $c <= 0x000D) ||
0x0020 === $c ||
0x0085 === $c ||
($c >= 0x200E && $c <= 0x200F) ||
0x2028 === $c ||
0x2029 === $c
;
}
/**
* Code point equivalent of regex `\p{Pattern_Syntax}`.
* See https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt.
*/
public static function isPatternSyntax(int $c): bool
{
return
($c >= 0x0021 && $c <= 0x0023) ||
0x0024 === $c ||
($c >= 0x0025 && $c <= 0x0027) ||
0x0028 === $c ||
0x0029 === $c ||
0x002A === $c ||
0x002B === $c ||
0x002C === $c ||
0x002D === $c ||
($c >= 0x002E && $c <= 0x002F) ||
($c >= 0x003A && $c <= 0x003B) ||
($c >= 0x003C && $c <= 0x003E) ||
($c >= 0x003F && $c <= 0x0040) ||
0x005B === $c ||
0x005C === $c ||
0x005D === $c ||
0x005E === $c ||
0x0060 === $c ||
0x007B === $c ||
0x007C === $c ||
0x007D === $c ||
0x007E === $c ||
0x00A1 === $c ||
($c >= 0x00A2 && $c <= 0x00A5) ||
0x00A6 === $c ||
0x00A7 === $c ||
0x00A9 === $c ||
0x00AB === $c ||
0x00AC === $c ||
0x00AE === $c ||
0x00B0 === $c ||
0x00B1 === $c ||
0x00B6 === $c ||
0x00BB === $c ||
0x00BF === $c ||
0x00D7 === $c ||
0x00F7 === $c ||
($c >= 0x2010 && $c <= 0x2015) ||
($c >= 0x2016 && $c <= 0x2017) ||
0x2018 === $c ||
0x2019 === $c ||
0x201A === $c ||
($c >= 0x201B && $c <= 0x201C) ||
0x201D === $c ||
0x201E === $c ||
0x201F === $c ||
($c >= 0x2020 && $c <= 0x2027) ||
($c >= 0x2030 && $c <= 0x2038) ||
0x2039 === $c ||
0x203A === $c ||
($c >= 0x203B && $c <= 0x203E) ||
($c >= 0x2041 && $c <= 0x2043) ||
0x2044 === $c ||
0x2045 === $c ||
0x2046 === $c ||
($c >= 0x2047 && $c <= 0x2051) ||
0x2052 === $c ||
0x2053 === $c ||
($c >= 0x2055 && $c <= 0x205E) ||
($c >= 0x2190 && $c <= 0x2194) ||
($c >= 0x2195 && $c <= 0x2199) ||
($c >= 0x219A && $c <= 0x219B) ||
($c >= 0x219C && $c <= 0x219F) ||
0x21A0 === $c ||
($c >= 0x21A1 && $c <= 0x21A2) ||
0x21A3 === $c ||
($c >= 0x21A4 && $c <= 0x21A5) ||
0x21A6 === $c ||
($c >= 0x21A7 && $c <= 0x21AD) ||
0x21AE === $c ||
($c >= 0x21AF && $c <= 0x21CD) ||
($c >= 0x21CE && $c <= 0x21CF) ||
($c >= 0x21D0 && $c <= 0x21D1) ||
0x21D2 === $c ||
0x21D3 === $c ||
0x21D4 === $c ||
($c >= 0x21D5 && $c <= 0x21F3) ||
($c >= 0x21F4 && $c <= 0x22FF) ||
($c >= 0x2300 && $c <= 0x2307) ||
0x2308 === $c ||
0x2309 === $c ||
0x230A === $c ||
0x230B === $c ||
($c >= 0x230C && $c <= 0x231F) ||
($c >= 0x2320 && $c <= 0x2321) ||
($c >= 0x2322 && $c <= 0x2328) ||
0x2329 === $c ||
0x232A === $c ||
($c >= 0x232B && $c <= 0x237B) ||
0x237C === $c ||
($c >= 0x237D && $c <= 0x239A) ||
($c >= 0x239B && $c <= 0x23B3) ||
($c >= 0x23B4 && $c <= 0x23DB) ||
($c >= 0x23DC && $c <= 0x23E1) ||
($c >= 0x23E2 && $c <= 0x2426) ||
($c >= 0x2427 && $c <= 0x243F) ||
($c >= 0x2440 && $c <= 0x244A) ||
($c >= 0x244B && $c <= 0x245F) ||
($c >= 0x2500 && $c <= 0x25B6) ||
0x25B7 === $c ||
($c >= 0x25B8 && $c <= 0x25C0) ||
0x25C1 === $c ||
($c >= 0x25C2 && $c <= 0x25F7) ||
($c >= 0x25F8 && $c <= 0x25FF) ||
($c >= 0x2600 && $c <= 0x266E) ||
0x266F === $c ||
($c >= 0x2670 && $c <= 0x2767) ||
0x2768 === $c ||
0x2769 === $c ||
0x276A === $c ||
0x276B === $c ||
0x276C === $c ||
0x276D === $c ||
0x276E === $c ||
0x276F === $c ||
0x2770 === $c ||
0x2771 === $c ||
0x2772 === $c ||
0x2773 === $c ||
0x2774 === $c ||
0x2775 === $c ||
($c >= 0x2794 && $c <= 0x27BF) ||
($c >= 0x27C0 && $c <= 0x27C4) ||
0x27C5 === $c ||
0x27C6 === $c ||
($c >= 0x27C7 && $c <= 0x27E5) ||
0x27E6 === $c ||
0x27E7 === $c ||
0x27E8 === $c ||
0x27E9 === $c ||
0x27EA === $c ||
0x27EB === $c ||
0x27EC === $c ||
0x27ED === $c ||
0x27EE === $c ||
0x27EF === $c ||
($c >= 0x27F0 && $c <= 0x27FF) ||
($c >= 0x2800 && $c <= 0x28FF) ||
($c >= 0x2900 && $c <= 0x2982) ||
0x2983 === $c ||
0x2984 === $c ||
0x2985 === $c ||
0x2986 === $c ||
0x2987 === $c ||
0x2988 === $c ||
0x2989 === $c ||
0x298A === $c ||
0x298B === $c ||
0x298C === $c ||
0x298D === $c ||
0x298E === $c ||
0x298F === $c ||
0x2990 === $c ||
0x2991 === $c ||
0x2992 === $c ||
0x2993 === $c ||
0x2994 === $c ||
0x2995 === $c ||
0x2996 === $c ||
0x2997 === $c ||
0x2998 === $c ||
($c >= 0x2999 && $c <= 0x29D7) ||
0x29D8 === $c ||
0x29D9 === $c ||
0x29DA === $c ||
0x29DB === $c ||
($c >= 0x29DC && $c <= 0x29FB) ||
0x29FC === $c ||
0x29FD === $c ||
($c >= 0x29FE && $c <= 0x2AFF) ||
($c >= 0x2B00 && $c <= 0x2B2F) ||
($c >= 0x2B30 && $c <= 0x2B44) ||
($c >= 0x2B45 && $c <= 0x2B46) ||
($c >= 0x2B47 && $c <= 0x2B4C) ||
($c >= 0x2B4D && $c <= 0x2B73) ||
($c >= 0x2B74 && $c <= 0x2B75) ||
($c >= 0x2B76 && $c <= 0x2B95) ||
0x2B96 === $c ||
($c >= 0x2B97 && $c <= 0x2BFF) ||
($c >= 0x2E00 && $c <= 0x2E01) ||
0x2E02 === $c ||
0x2E03 === $c ||
0x2E04 === $c ||
0x2E05 === $c ||
($c >= 0x2E06 && $c <= 0x2E08) ||
0x2E09 === $c ||
0x2E0A === $c ||
0x2E0B === $c ||
0x2E0C === $c ||
0x2E0D === $c ||
($c >= 0x2E0E && $c <= 0x2E16) ||
0x2E17 === $c ||
($c >= 0x2E18 && $c <= 0x2E19) ||
0x2E1A === $c ||
0x2E1B === $c ||
0x2E1C === $c ||
0x2E1D === $c ||
($c >= 0x2E1E && $c <= 0x2E1F) ||
0x2E20 === $c ||
0x2E21 === $c ||
0x2E22 === $c ||
0x2E23 === $c ||
0x2E24 === $c ||
0x2E25 === $c ||
0x2E26 === $c ||
0x2E27 === $c ||
0x2E28 === $c ||
0x2E29 === $c ||
($c >= 0x2E2A && $c <= 0x2E2E) ||
0x2E2F === $c ||
($c >= 0x2E30 && $c <= 0x2E39) ||
($c >= 0x2E3A && $c <= 0x2E3B) ||
($c >= 0x2E3C && $c <= 0x2E3F) ||
0x2E40 === $c ||
0x2E41 === $c ||
0x2E42 === $c ||
($c >= 0x2E43 && $c <= 0x2E4F) ||
($c >= 0x2E50 && $c <= 0x2E51) ||
0x2E52 === $c ||
($c >= 0x2E53 && $c <= 0x2E7F) ||
($c >= 0x3001 && $c <= 0x3003) ||
0x3008 === $c ||
0x3009 === $c ||
0x300A === $c ||
0x300B === $c ||
0x300C === $c ||
0x300D === $c ||
0x300E === $c ||
0x300F === $c ||
0x3010 === $c ||
0x3011 === $c ||
($c >= 0x3012 && $c <= 0x3013) ||
0x3014 === $c ||
0x3015 === $c ||
0x3016 === $c ||
0x3017 === $c ||
0x3018 === $c ||
0x3019 === $c ||
0x301A === $c ||
0x301B === $c ||
0x301C === $c ||
0x301D === $c ||
($c >= 0x301E && $c <= 0x301F) ||
0x3020 === $c ||
0x3030 === $c ||
0xFD3E === $c ||
0xFD3F === $c ||
($c >= 0xFE45 && $c <= 0xFE46)
;
}
public static function fromCodePoint(int ...$codePoints): string
{
$elements = '';
$length = \count($codePoints);
$i = 0;
while ($length > $i) {
$code = $codePoints[$i++];
if ($code > 0x10FFFF) {
throw RangeError($code + ' is not a valid code point');
}
$elements .= mb_chr($code, 'UTF-8');
}
return $elements;
}
public static function matchIdentifierAtIndex(string $s, int $index): string
{
$match = [];
while (true) {
$c = s($s)->codePointsAt($index)[0] ?? null;
if (null === $c || self::isWhiteSpace($c) || self::isPatternSyntax($c)) {
break;
}
$match[] = $c;
$index += $c >= 0x10000 ? 2 : 1;
}
return self::fromCodePoint(...$match);
}
public static function isSafeInteger(mixed $value): bool
{
return \is_int($value) && is_finite($value) && abs($value) <= \PHP_INT_MAX;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\MessageParameters\Extractor;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
interface ExtractorInterface
{
/**
* @return array<string, array{ type: 'number' }|array{ type: 'string', values?: list<string>}|array{ type: 'date' }>
*
* @throws \Exception
*/
public function extract(string $message): array;
}

View File

@@ -0,0 +1,87 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\MessageParameters\Extractor;
use Symfony\UX\Translator\Intl\IntlMessageParser;
use Symfony\UX\Translator\Intl\Type;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
final class IntlMessageParametersExtractor implements ExtractorInterface
{
public function extract(string $message): array
{
$parameters = [];
$intlMessageParser = new IntlMessageParser($message);
$ast = $intlMessageParser->parse();
if ($ast['err']) {
throw new \InvalidArgumentException(sprintf('The message "%s" is not a valid Intl message.', $message));
}
$nodes = $ast['val'];
while ([] !== $nodes) {
$node = array_shift($nodes);
switch ($node['type']) {
case Type::LITERAL:
break;
case Type::ARGUMENT:
$parameters[$node['value']] = ['type' => 'string'];
break;
case Type::NUMBER:
$parameters[$node['value']] = ['type' => 'number'];
break;
case Type::DATE:
case Type::TIME:
$parameters[$node['value']] = ['type' => 'date'];
break;
case Type::SELECT:
$parameters[$node['value']] = [
'type' => 'string',
'values' => array_keys($node['options']),
];
foreach ($node['options'] as $option) {
foreach ($option['value'] as $nodeValue) {
$nodes[] = $nodeValue;
}
}
break;
case Type::PLURAL:
$parameters[$node['value']] = [
'type' => 'number',
];
foreach ($node['options'] as $option) {
foreach ($option['value'] as $nodeValue) {
$nodes[] = $nodeValue;
}
}
break;
}
}
return $parameters;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\MessageParameters\Extractor;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
final class MessageParametersExtractor implements ExtractorInterface
{
private const RE_PARAMETER = '/(%\w+%)|({{ \w+ }})/';
public function extract(string $message): array
{
$parameters = [];
if (false !== preg_match_all(self::RE_PARAMETER, $message, $matches)) {
foreach ($matches[0] as $match) {
$parameters[$match] = [
'type' => '%count%' === $match ? 'number' : 'string',
];
}
}
return $parameters;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\MessageParameters\Printer;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @experimental
*/
final class TypeScriptMessageParametersPrinter
{
/**
* @param array<string, array{ type: 'number' }|array{ type: 'string', values?: list<string>}|array{ type: 'date' }> $parameters
*/
public function print(array $parameters): string
{
if ([] === $parameters) {
return 'NoParametersType';
}
$type = '{ ';
foreach ($parameters as $parameterName => $parameter) {
switch ($parameter['type']) {
case 'number':
$value = 'number';
break;
case 'string':
if (\is_array($parameter['values'] ?? null)) {
$value = implode(
'|',
array_map(
fn (string $val) => 'other' === $val ? 'string' : "'".$val."'",
$parameter['values']
)
);
} else {
$value = 'string';
}
break;
case 'date':
$value = 'Date';
break;
default:
throw new \InvalidArgumentException(sprintf('Unknown type "%s" for parameter "%s"', $parameter['type'], $parameterName));
}
$type .= sprintf("'%s': %s, ", $parameterName, $value);
}
$type = rtrim($type, ', ');
$type .= ' }';
return $type;
}
}

162
src/TranslationsDumper.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
use function Symfony\Component\String\s;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @final
*
* @experimental
*
* @phpstan-type Domain string
* @phpstan-type Locale string
* @phpstan-type MessageId string
*/
class TranslationsDumper
{
public function __construct(
private string $dumpDir,
private MessageParametersExtractor $messageParametersExtractor,
private IntlMessageParametersExtractor $intlMessageParametersExtractor,
private TypeScriptMessageParametersPrinter $typeScriptMessageParametersPrinter,
private Filesystem $filesystem,
) {
}
public function dump(MessageCatalogueInterface ...$catalogues): void
{
$this->filesystem->mkdir($this->dumpDir);
$this->filesystem->remove($this->dumpDir.'/index.js');
$this->filesystem->remove($this->dumpDir.'/index.d.ts');
$this->filesystem->remove($this->dumpDir.'/configuration.js');
$this->filesystem->remove($this->dumpDir.'/configuration.d.ts');
$translationsJs = '';
$translationsTs = "import { Message, NoParametersType } from '@symfony/ux-translator';\n\n";
foreach ($this->getTranslations(...$catalogues) as $translationId => $translationsByDomainAndLocale) {
$constantName = s($translationId)->ascii()->snake()->upper()->toString();
$translationsJs .= sprintf(
"export const %s = %s;\n",
$constantName,
json_encode([
'id' => $translationId,
'translations' => $translationsByDomainAndLocale,
], \JSON_THROW_ON_ERROR),
);
$translationsTs .= sprintf(
"export declare const %s: %s;\n",
$constantName,
$this->getTranslationsTypeScriptTypeDefinition($translationsByDomainAndLocale)
);
}
$this->filesystem->dumpFile($this->dumpDir.'/index.js', $translationsJs);
$this->filesystem->dumpFile($this->dumpDir.'/index.d.ts', $translationsTs);
$this->filesystem->dumpFile($this->dumpDir.'/configuration.js', sprintf(
"export const localeFallbacks = %s;\n",
json_encode($this->getLocaleFallbacks(...$catalogues), \JSON_THROW_ON_ERROR)
));
$this->filesystem->dumpFile($this->dumpDir.'/configuration.d.ts', <<<'TS'
import { LocaleType } from '@symfony/ux-translator';
export declare const localeFallbacks: Record<LocaleType, LocaleType>;
TS
);
}
/**
* @return array<MessageId, array<Domain, array<Locale, string>>>
*/
private function getTranslations(MessageCatalogueInterface ...$catalogues): array
{
$translations = [];
foreach ($catalogues as $catalogue) {
$locale = $catalogue->getLocale();
foreach ($catalogue->getDomains() as $domain) {
foreach ($catalogue->all($domain) as $id => $message) {
$realDomain = $catalogue->has($id, $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)
? $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX
: $domain;
$translations[$id] ??= [];
$translations[$id][$realDomain] ??= [];
$translations[$id][$realDomain][$locale] = $message;
}
}
}
return $translations;
}
/**
* @param array<Domain, array<Locale, string>> $translationsByDomainAndLocale
*
* @throws \Exception
*/
private function getTranslationsTypeScriptTypeDefinition(array $translationsByDomainAndLocale): string
{
$parametersTypes = [];
$locales = [];
foreach ($translationsByDomainAndLocale as $domain => $translationsByLocale) {
foreach ($translationsByLocale as $locale => $translation) {
try {
$parameters = str_ends_with($domain, MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)
? $this->intlMessageParametersExtractor->extract($translation)
: $this->messageParametersExtractor->extract($translation);
} catch (\Throwable $e) {
throw new \Exception(sprintf('Error while extracting parameters from message "%s" in domain "%s" and locale "%s".', $translation, $domain, $locale), previous: $e);
}
$parametersTypes[$domain] = $this->typeScriptMessageParametersPrinter->print($parameters);
$locales[] = $locale;
}
}
return sprintf(
'Message<{ %s }, %s>',
implode(', ', array_reduce(
array_keys($parametersTypes),
fn (array $carry, string $domain) => [
...$carry,
sprintf("'%s': { parameters: %s }", $domain, $parametersTypes[$domain]),
],
[],
)),
implode('|', array_map(fn (string $locale) => "'$locale'", array_unique($locales))),
);
}
private function getLocaleFallbacks(MessageCatalogueInterface ...$catalogues): array
{
$localesFallbacks = [];
foreach ($catalogues as $catalogue) {
$localesFallbacks[$catalogue->getLocale()] = $catalogue->getFallbackCatalogue()?->getLocale();
}
return $localesFallbacks;
}
}

29
src/TranslatorBundle.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @final
*
* @experimental
*/
class TranslatorBundle extends Bundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Symfony\UX\Translator\Tests\CacheWarmer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\UX\Translator\CacheWarmer\TranslationsCacheWarmer;
use Symfony\UX\Translator\TranslationsDumper;
final class TranslationsCacheWarmerTest extends TestCase
{
protected static $cacheDir;
public static function setUpBeforeClass(): void
{
self::$cacheDir = tempnam(sys_get_temp_dir(), 'sf_cache_warmer_dir');
}
public static function tearDownAfterClass(): void
{
@unlink(self::$cacheDir);
}
public function test()
{
$translatorBag = new TranslatorBag();
$translatorBag->addCatalogue(
new MessageCatalogue('en', [
'messages' => [
'foo' => 'bar',
],
])
);
$translationsDumperMock = $this->createMock(TranslationsDumper::class);
$translationsDumperMock
->expects($this->once())
->method('dump')
->with(...$translatorBag->getCatalogues());
$translationsCacheWarmer = new TranslationsCacheWarmer(
$translatorBag,
$translationsDumperMock
);
$translationsCacheWarmer->warmUp(self::$cacheDir);
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace Symfony\UX\Translator\Tests\Intl;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Translator\Intl\ErrorKind;
use Symfony\UX\Translator\Intl\IntlMessageParser;
use Symfony\UX\Translator\Intl\Location;
use Symfony\UX\Translator\Intl\Position;
use Symfony\UX\Translator\Intl\Type;
class IntlMessageParserTest extends TestCase
{
/**
* @dataProvider provideParse
*/
public function testIntlMessageParser(string $message, array $expectedAst): void
{
$intlMessageParser = new IntlMessageParser($message);
static::assertEquals($expectedAst, $intlMessageParser->parse());
}
public function provideParse()
{
yield 'no parameters' => [
'Hello world!',
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'Hello world!',
'location' => new Location(new Position(0, 1, 1), new Position(12, 1, 13)),
],
],
'err' => null,
],
];
yield 'with emoji' => [
"We hope we've met your expectations 😊",
[
'val' => [
[
'type' => Type::LITERAL,
'value' => "We hope we've met your expectations 😊",
'location' => new Location(new Position(0, 1, 1), new Position(37, 1, 38)),
],
],
'err' => null,
],
];
yield 'with HTML' => [
'Hello <b>world</b>!',
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'Hello <b>world</b>!',
'location' => new Location(new Position(0, 1, 1), new Position(19, 1, 20)),
],
],
'err' => null,
],
];
yield 'one parameter' => [
'Hello {name}!',
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'Hello ',
'location' => new Location(new Position(0, 1, 1), new Position(6, 1, 7)),
],
[
'type' => Type::ARGUMENT,
'value' => 'name',
'location' => new Location(new Position(6, 1, 7), new Position(12, 1, 13)),
],
[
'type' => Type::LITERAL,
'value' => '!',
'location' => new Location(new Position(12, 1, 13), new Position(13, 1, 14)),
],
],
'err' => null,
],
];
yield 'multiples parameters' => [
'Hello {firstName}, welcome to {hotelName}',
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'Hello ',
'location' => new Location(new Position(0, 1, 1), new Position(6, 1, 7)),
],
[
'type' => Type::ARGUMENT,
'value' => 'firstName',
'location' => new Location(new Position(6, 1, 7), new Position(17, 1, 18)),
],
[
'type' => Type::LITERAL,
'value' => ', welcome to ',
'location' => new Location(new Position(17, 1, 18), new Position(30, 1, 31)),
],
[
'type' => Type::ARGUMENT,
'value' => 'hotelName',
'location' => new Location(new Position(30, 1, 31), new Position(41, 1, 42)),
],
],
'err' => null,
],
];
yield 'plural' => [
<<<'EOT'
You have {itemCount, plural,
=0 {no items}
one {1 item}
other {{itemCount} items}
}.
EOT,
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'You have ',
'location' => new Location(new Position(0, 1, 1), new Position(9, 1, 10)),
],
[
'type' => Type::PLURAL,
'value' => 'itemCount',
'offset' => 0,
'options' => [
'=0' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'no items',
'location' => new Location(new Position(37, 2, 9), new Position(45, 2, 17)),
],
],
'location' => new Location(new Position(36, 2, 8), new Position(46, 2, 18)),
],
'one' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => '1 item',
'location' => new Location(new Position(56, 3, 10), new Position(62, 3, 16)),
],
],
'location' => new Location(new Position(55, 3, 9), new Position(63, 3, 17)),
],
'other' => [
'value' => [
[
'type' => Type::ARGUMENT,
'value' => 'itemCount',
'location' => new Location(new Position(75, 4, 12), new Position(86, 4, 23)),
],
[
'type' => Type::LITERAL,
'value' => ' items',
'location' => new Location(new Position(86, 4, 23), new Position(92, 4, 29)),
],
],
'location' => new Location(new Position(74, 4, 11), new Position(93, 4, 30)),
],
],
'pluralType' => 'cardinal',
'location' => new Location(new Position(9, 1, 10), new Position(95, 5, 2)),
],
[
'type' => Type::LITERAL,
'value' => '.',
'location' => new Location(new Position(95, 5, 2), new Position(96, 5, 3)),
],
],
'err' => null,
],
];
yield 'many parameters, plural, select, with HTML' => [
<<<'EOT'
I have {count, plural,
one{a {
gender, select,
male{male}
female{female}
other{male}
} <b>dog</b>
}
other{many dogs}} and {count, plural,
one{a {
gender, select,
male{male}
female{female}
other{male}
} <strong>cat</strong>
}
other{many cats}}
EOT,
[
'val' => [
[
'type' => Type::LITERAL,
'value' => 'I have ',
'location' => new Location(new Position(0, 1, 1), new Position(7, 1, 8)),
],
[
'type' => Type::PLURAL,
'value' => 'count',
'offset' => 0,
'options' => [
'one' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'a ',
'location' => new Location(new Position(31, 2, 9), new Position(33, 2, 11)),
],
[
'type' => Type::SELECT,
'value' => 'gender',
'options' => [
'male' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'male',
'location' => new Location(new Position(76, 4, 18), new Position(80, 4, 22)),
],
],
'location' => new Location(new Position(75, 4, 17), new Position(81, 4, 23)),
],
'female' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'female',
'location' => new Location(new Position(101, 5, 20), new Position(107, 5, 26)),
],
],
'location' => new Location(new Position(100, 5, 19), new Position(108, 5, 27)),
],
'other' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'male',
'location' => new Location(new Position(127, 6, 19), new Position(131, 6, 23)),
],
],
'location' => new Location(new Position(126, 6, 18), new Position(132, 6, 24)),
],
],
'location' => new Location(new Position(33, 2, 11), new Position(142, 7, 10)),
],
[
'type' => Type::LITERAL,
'value' => " <b>dog</b>\n ",
'location' => new Location(new Position(142, 7, 10), new Position(158, 8, 5)),
],
],
'location' => new Location(new Position(30, 2, 8), new Position(159, 8, 6)),
],
'other' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'many dogs',
'location' => new Location(new Position(170, 9, 11), new Position(179, 9, 20)),
],
],
'location' => new Location(new Position(169, 9, 10), new Position(180, 9, 21)),
],
],
'pluralType' => 'cardinal',
'location' => new Location(new Position(7, 1, 8), new Position(181, 9, 22)),
],
[
'type' => Type::LITERAL,
'value' => ' and ',
'location' => new Location(new Position(181, 9, 22), new Position(186, 9, 27)),
],
[
'type' => Type::PLURAL,
'value' => 'count',
'location' => new Location(new Position(186, 9, 27), new Position(402, 17, 26)),
'offset' => 0,
'options' => [
'one' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'a ',
'location' => new Location(new Position(214, 10, 13), new Position(216, 10, 15)),
],
[
'type' => Type::SELECT,
'options' => [
'male' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'male',
'location' => new Location(new Position(267, 12, 22), new Position(271, 12, 26)),
],
],
'location' => new Location(new Position(266, 12, 21), new Position(272, 12, 27)),
],
'female' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'female',
'location' => new Location(new Position(296, 13, 24), new Position(302, 13, 30)),
],
],
'location' => new Location(new Position(295, 13, 23), new Position(303, 13, 31)),
],
'other' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'male',
'location' => new Location(new Position(326, 14, 23), new Position(330, 14, 27)),
],
],
'location' => new Location(new Position(325, 14, 22), new Position(331, 14, 28)),
],
],
'value' => 'gender',
'location' => new Location(new Position(216, 10, 15), new Position(345, 15, 14)),
],
[
'type' => Type::LITERAL,
'value' => " <strong>cat</strong>\n ",
'location' => new Location(new Position(345, 15, 14), new Position(375, 16, 9)),
],
],
'location' => new Location(new Position(213, 10, 12), new Position(376, 16, 10)),
],
'other' => [
'value' => [
[
'type' => Type::LITERAL,
'value' => 'many cats',
'location' => new Location(new Position(391, 17, 15), new Position(400, 17, 24)),
],
],
'location' => new Location(new Position(390, 17, 14), new Position(401, 17, 25)),
],
],
'pluralType' => 'cardinal',
],
],
'err' => null,
],
];
}
public function testParseWithUnclosedBracket()
{
$intlMessageParser = new IntlMessageParser('Hello {name!');
static::assertEquals([
'val' => null,
'err' => [
'kind' => ErrorKind::MALFORMED_ARGUMENT,
'location' => new Location(new Position(6, 1, 7), new Position(11, 1, 12)),
'message' => 'Hello {name!',
],
], $intlMessageParser->parse());
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\Tests\Kernel;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @internal
*/
trait AppKernelTrait
{
public function getCacheDir(): string
{
return $this->createTmpDir('cache');
}
public function getLogDir(): string
{
return $this->createTmpDir('logs');
}
private function createTmpDir(string $type): string
{
$dir = sys_get_temp_dir().'/translator_bundle/'.uniqid($type.'_', true);
if (!file_exists($dir)) {
mkdir($dir, 0777, true);
}
return $dir;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\Tests\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\UX\Translator\TranslatorBundle;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @internal
*/
class EmptyAppKernel extends Kernel
{
use AppKernelTrait;
public function registerBundles(): iterable
{
return [new TranslatorBundle()];
}
public function registerContainerConfiguration(LoaderInterface $loader)
{
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\Translator\Tests\Kernel;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\UX\Translator\TranslatorBundle;
/**
* @author Hugo Alliaume <hugo@alliau.me>
*
* @internal
*/
class FrameworkAppKernel extends Kernel
{
use AppKernelTrait;
public function registerBundles(): iterable
{
return [new FrameworkBundle(), new TranslatorBundle()];
}
public function registerContainerConfiguration(LoaderInterface $loader)
{
$loader->load(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'secret' => '$ecret',
'test' => true,
'translator' => [
'fallbacks' => ['en'],
],
'http_method_override' => false,
]);
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Symfony\UX\Translator\Tests\MessageParameters;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor;
class IntlMessageParametersExtractorTest extends TestCase
{
/**
* @dataProvider provideExtract
*/
public function testExtract(string $message, array $expectedParameters)
{
$intlMessageParametersExtractor = new IntlMessageParametersExtractor();
static::assertEquals($expectedParameters, $intlMessageParametersExtractor->extract($message));
}
public function provideExtract()
{
yield [
'Symfony is great!',
[],
];
yield [
'Symfony is {what}!',
['what' => ['type' => 'string']],
];
yield [
'{framework} is {what}!',
[
'framework' => ['type' => 'string'],
'what' => ['type' => 'string'],
],
];
yield [
'I have {numCats, number} cats.',
['numCats' => ['type' => 'number']],
];
yield [
'Almost {pctBlack, number, ::percent} of my cats are black.',
['pctBlack' => ['type' => 'number']],
];
yield [
'The price of this bagel is {num, number, ::sign-always compact-short currency/GBP}',
['num' => ['type' => 'number']],
];
yield [
'Coupon expires at {expires, time, short}',
['expires' => ['type' => 'date']],
];
yield [
<<<TXT
{gender, select,
male {He}
female {She}
other {They}
} will respond shortly.
TXT,
[
'gender' => ['type' => 'string', 'values' => ['male', 'female', 'other']],
],
];
yield [
<<<TXT
{taxableArea, select,
yes {An additional {taxRate, number, percent} tax will be collected.}
other {No taxes apply.}
}
TXT,
[
'taxableArea' => ['type' => 'string', 'values' => ['yes', 'other']],
'taxRate' => ['type' => 'number'],
],
];
yield [
<<<TXT
{gender_of_host, select,
female {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to her party.}
=2 {{host} invites {guest} and one other person to her party.}
other {{host} invites {guest} and # other people to her party.}
}}
male {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to his party.}
=2 {{host} invites {guest} and one other person to his party.}
other {{host} invites {guest} and # other people to his party.}
}}
other {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to their party.}
=2 {{host} invites {guest} and one other person to their party.}
other {{host} invites {guest} and # other people to their party.}
}}
}
TXT,
[
'gender_of_host' => ['type' => 'string', 'values' => ['female', 'male', 'other']],
'num_guests' => ['type' => 'number'],
'host' => ['type' => 'string'],
'guest' => ['type' => 'string'],
],
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Symfony\UX\Translator\Tests\MessageParameters;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
class MessageParametersExtractorTest extends TestCase
{
/**
* @dataProvider provideExtract
*/
public function testExtract(string $message, array $expectedParameters): void
{
$messageParametersExtractor = new MessageParametersExtractor();
static::assertEquals($messageParametersExtractor->extract($message), $expectedParameters);
}
public function provideExtract()
{
yield [
'Symfony is great!',
[],
];
yield [
'Symfony is %what%!',
['%what%' => ['type' => 'string']],
];
yield [
'%framework% is %what%!',
[
'%framework%' => ['type' => 'string'],
'%what%' => ['type' => 'string'],
],
];
yield [
'%framework% have more than %years% years!',
[
'%framework%' => ['type' => 'string'],
'%years%' => ['type' => 'string'],
],
];
yield [
'{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples',
['%count%' => ['type' => 'number']],
];
yield [
'There is 1 apple|There are %count% apples',
['%count%' => ['type' => 'number']],
];
yield [
'You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.',
['{{ limit }}' => ['type' => 'string']],
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Symfony\UX\Translator\Tests\Printer;
use PHPUnit\Framework\TestCase;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
class TypeScriptMessageParametersPrinterTest extends TestCase
{
/**
* @dataProvider providePrint
*/
public function testPrint(array $parameters, string $expectedTypeScriptType)
{
$typeScriptMessageParametersPrinter = new TypeScriptMessageParametersPrinter();
static::assertSame($expectedTypeScriptType, $typeScriptMessageParametersPrinter->print($parameters));
}
public function providePrint()
{
yield [
[],
'NoParametersType',
];
yield [
['%what%' => ['type' => 'string']],
"{ '%what%': string }",
];
yield [
['what' => ['type' => 'string']],
"{ 'what': string }",
];
yield [
[
'framework' => ['type' => 'string'],
'what' => ['type' => 'string'],
],
"{ 'framework': string, 'what': string }",
];
yield [
[
'%framework%' => ['type' => 'string'],
'%what%' => ['type' => 'string'],
],
"{ '%framework%': string, '%what%': string }",
];
yield [
[
'%framework%' => ['type' => 'string'],
'%years%' => ['type' => 'string'],
],
"{ '%framework%': string, '%years%': string }",
];
yield [
['%count%' => ['type' => 'number']],
"{ '%count%': number }",
];
yield [
['{{ limit }}' => ['type' => 'string']],
"{ '{{ limit }}': string }",
];
yield [
['numCats' => ['type' => 'number']],
"{ 'numCats': number }",
];
yield [
['num' => ['type' => 'number']],
"{ 'num': number }",
];
yield [
['expires' => ['type' => 'date']],
"{ 'expires': Date }",
];
yield [
[
'gender' => ['type' => 'string', 'values' => ['male', 'female', 'other']],
],
"{ 'gender': 'male'|'female'|string }",
];
yield [
[
'taxableArea' => ['type' => 'string', 'values' => ['yes', 'other']],
'taxRate' => ['type' => 'number'],
],
"{ 'taxableArea': 'yes'|string, 'taxRate': number }",
];
yield [
[
'gender_of_host' => ['type' => 'string', 'values' => ['female', 'male', 'other']],
'num_guests' => ['type' => 'number'],
'host' => ['type' => 'string'],
'guest' => ['type' => 'string'],
],
"{ 'gender_of_host': 'female'|'male'|string, 'num_guests': number, 'host': string, 'guest': string }",
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Symfony\UX\Translator\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\UX\Translator\MessageParameters\Extractor\IntlMessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Extractor\MessageParametersExtractor;
use Symfony\UX\Translator\MessageParameters\Printer\TypeScriptMessageParametersPrinter;
use Symfony\UX\Translator\TranslationsDumper;
class TranslationsDumperTest extends TestCase
{
protected static $translationsDumpDir;
public static function setUpBeforeClass(): void
{
self::$translationsDumpDir = sys_get_temp_dir().'/sf_ux_translator/'.uniqid('translations', true);
}
public static function tearDownAfterClass(): void
{
@rmdir(self::$translationsDumpDir);
}
public function testDump(): void
{
$translationsDumper = new TranslationsDumper(
self::$translationsDumpDir,
new MessageParametersExtractor(),
new IntlMessageParametersExtractor(),
new TypeScriptMessageParametersPrinter(),
new Filesystem(),
);
$translationsDumper->dump(
new MessageCatalogue('en', [
'messages+intl-icu' => [
'notification.comment_created' => 'Your post received a comment!',
'notification.comment_created.description' => 'Your post "{title}" has received a new comment. You can read the comment by following <a href="{link}">this link</a>',
'post.num_comments' => '{count, plural, one {# comment} other {# comments}}',
],
'messages' => [
'symfony.great' => 'Symfony is awesome!',
'symfony.what' => 'Symfony is %what%!',
'apples.count.0' => 'There is 1 apple|There are %count% apples',
'apples.count.1' => '{1} There is one apple|]1,Inf] There are %count% apples',
'apples.count.2' => '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples',
'apples.count.3' => 'one: There is one apple|more: There are %count% apples',
'apples.count.4' => 'one: There is one apple|more: There are more than one apple',
'what.count.1' => '{1} There is one %what%|]1,Inf] There are %count% %what%',
'what.count.2' => '{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%',
'what.count.3' => 'one: There is one %what%|more: There are %count% %what%',
'what.count.4' => 'one: There is one %what%|more: There are more than one %what%',
],
'foobar' => [
'post.num_comments' => 'There is 1 comment|There are %count% comments',
],
]),
new MessageCatalogue('fr', [
'messages+intl-icu' => [
'notification.comment_created' => 'Votre article a reçu un commentaire !',
'notification.comment_created.description' => 'Votre article "{title}" a reçu un nouveau commentaire. Vous pouvez lire le commentaire en suivant <a href="{link}">ce lien</a>',
'post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}',
],
'messages' => [
'symfony.great' => 'Symfony est génial !',
'symfony.what' => 'Symfony est %what%!',
'apples.count.0' => 'Il y a 1 pomme|Il y a %count% pommes',
'apples.count.1' => '{1} Il y a une pomme|]1,Inf] Il y a %count% pommes',
'apples.count.2' => '{0} Il n\'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes',
'apples.count.3' => 'one: Il y a une pomme|more: Il y a %count% pommes',
'apples.count.4' => 'one: Il y a une pomme|more: Il y a plus d\'une pomme',
'what.count.1' => '{1} Il y a une %what%|]1,Inf] Il y a %count% %what%',
'what.count.2' => '{0} Il n\'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%',
'what.count.3' => 'one: Il y a une %what%|more: Il y a %count% %what%',
'what.count.4' => 'one: Il y a une %what%|more: Il y a more than one %what%',
],
'foobar' => [
'post.num_comments' => 'Il y a 1 comment|Il y a %count% comments',
],
])
);
$this->assertFileExists(self::$translationsDumpDir.'/index.js');
$this->assertFileExists(self::$translationsDumpDir.'/index.d.ts');
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.js', <<<'JAVASCRIPT'
export const NOTIFICATION_COMMENT_CREATED = {"id":"notification.comment_created","translations":{"messages+intl-icu":{"en":"Your post received a comment!","fr":"Votre article a re\u00e7u un commentaire !"}}};
export const NOTIFICATION_COMMENT_CREATED_DESCRIPTION = {"id":"notification.comment_created.description","translations":{"messages+intl-icu":{"en":"Your post \"{title}\" has received a new comment. You can read the comment by following <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 SYMFONY_GREAT = {"id":"symfony.great","translations":{"messages":{"en":"Symfony is awesome!","fr":"Symfony est g\u00e9nial !"}}};
export const SYMFONY_WHAT = {"id":"symfony.what","translations":{"messages":{"en":"Symfony is %what%!","fr":"Symfony est %what%!"}}};
export const APPLES_COUNT0 = {"id":"apples.count.0","translations":{"messages":{"en":"There is 1 apple|There are %count% apples","fr":"Il y a 1 pomme|Il y a %count% pommes"}}};
export const APPLES_COUNT1 = {"id":"apples.count.1","translations":{"messages":{"en":"{1} There is one apple|]1,Inf] There are %count% apples","fr":"{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}};
export const APPLES_COUNT2 = {"id":"apples.count.2","translations":{"messages":{"en":"{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples","fr":"{0} Il n'y a pas de pommes|{1} Il y a une pomme|]1,Inf] Il y a %count% pommes"}}};
export const APPLES_COUNT3 = {"id":"apples.count.3","translations":{"messages":{"en":"one: There is one apple|more: There are %count% apples","fr":"one: Il y a une pomme|more: Il y a %count% pommes"}}};
export const APPLES_COUNT4 = {"id":"apples.count.4","translations":{"messages":{"en":"one: There is one apple|more: There are more than one apple","fr":"one: Il y a une pomme|more: Il y a plus d'une pomme"}}};
export const WHAT_COUNT1 = {"id":"what.count.1","translations":{"messages":{"en":"{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}};
export const WHAT_COUNT2 = {"id":"what.count.2","translations":{"messages":{"en":"{0} There are no %what%|{1} There is one %what%|]1,Inf] There are %count% %what%","fr":"{0} Il n'y a pas de %what%|{1} Il y a une %what%|]1,Inf] Il y a %count% %what%"}}};
export const WHAT_COUNT3 = {"id":"what.count.3","translations":{"messages":{"en":"one: There is one %what%|more: There are %count% %what%","fr":"one: Il y a une %what%|more: Il y a %count% %what%"}}};
export const WHAT_COUNT4 = {"id":"what.count.4","translations":{"messages":{"en":"one: There is one %what%|more: There are more than one %what%","fr":"one: Il y a une %what%|more: Il y a more than one %what%"}}};
JAVASCRIPT);
$this->assertStringEqualsFile(self::$translationsDumpDir.'/index.d.ts', <<<'TYPESCRIPT'
import { Message, NoParametersType } from '@symfony/ux-translator';
export declare const NOTIFICATION_COMMENT_CREATED: Message<{ 'messages+intl-icu': { parameters: NoParametersType } }, 'en'|'fr'>;
export declare const NOTIFICATION_COMMENT_CREATED_DESCRIPTION: Message<{ 'messages+intl-icu': { parameters: { 'title': string, 'link': string } } }, 'en'|'fr'>;
export declare const POST_NUM_COMMENTS: Message<{ 'messages+intl-icu': { parameters: { 'count': number } }, 'foobar': { parameters: { '%count%': number } } }, 'en'|'fr'>;
export declare const SYMFONY_GREAT: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
export declare const SYMFONY_WHAT: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
export declare const APPLES_COUNT0: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
export declare const APPLES_COUNT1: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
export declare const APPLES_COUNT2: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
export declare const APPLES_COUNT3: Message<{ 'messages': { parameters: { '%count%': number } } }, 'en'|'fr'>;
export declare const APPLES_COUNT4: Message<{ 'messages': { parameters: NoParametersType } }, 'en'|'fr'>;
export declare const WHAT_COUNT1: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
export declare const WHAT_COUNT2: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
export declare const WHAT_COUNT3: Message<{ 'messages': { parameters: { '%what%': string, '%count%': number } } }, 'en'|'fr'>;
export declare const WHAT_COUNT4: Message<{ 'messages': { parameters: { '%what%': string } } }, 'en'|'fr'>;
TYPESCRIPT);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Symfony\UX\Translator\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\UX\Translator\Tests\Kernel\EmptyAppKernel;
use Symfony\UX\Translator\Tests\Kernel\FrameworkAppKernel;
class TranslatorBundleTest extends TestCase
{
public function provideKernels()
{
yield 'empty' => [new EmptyAppKernel('test', true)];
yield 'framework' => [new FrameworkAppKernel('test', true)];
}
/**
* @dataProvider provideKernels
*/
public function testBootKernel(Kernel $kernel)
{
$kernel->boot();
$this->assertArrayHasKey('TranslatorBundle', $kernel->getBundles());
}
}