mirror of
https://github.com/symfony/ux-translator.git
synced 2026-03-24 00:12:19 +01:00
[Translator] Add Symfony UX Translator package
This commit is contained in:
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor
|
||||
composer.lock
|
||||
.php_cs.cache
|
||||
.phpunit.result.cache
|
||||
3
.symfony.bundle.yaml
Normal file
3
.symfony.bundle.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
branches: ["2.x"]
|
||||
maintained_branches: ["2.x"]
|
||||
doc_dir: "doc"
|
||||
5
CHANGELOG.md
Normal file
5
CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Component added
|
||||
19
LICENSE
Normal file
19
LICENSE
Normal 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
16
README.md
Normal 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
1
assets/dist/formatters/formatter.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function format(id: string, parameters: Record<string, string | number> | undefined, locale: string): string;
|
||||
1
assets/dist/formatters/intl-formatter.d.ts
vendored
Normal file
1
assets/dist/formatters/intl-formatter.d.ts
vendored
Normal 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
26
assets/dist/translator.d.ts
vendored
Normal 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;
|
||||
1
assets/dist/translator_controller.d.ts
vendored
Normal file
1
assets/dist/translator_controller.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './translator';
|
||||
253
assets/dist/translator_controller.js
vendored
Normal file
253
assets/dist/translator_controller.js
vendored
Normal 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
1
assets/dist/utils.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function strtr(string: string, replacePairs: Record<string, string | number>): string;
|
||||
1
assets/jest.config.js
Normal file
1
assets/jest.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../../../jest.config.js');
|
||||
20
assets/package.json
Normal file
20
assets/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
238
assets/src/formatters/formatter.ts
Normal file
238
assets/src/formatters/formatter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
27
assets/src/formatters/intl-formatter.ts
Normal file
27
assets/src/formatters/intl-formatter.ts
Normal 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
167
assets/src/translator.ts
Normal 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;
|
||||
}
|
||||
12
assets/src/translator_controller.ts
Normal file
12
assets/src/translator_controller.ts
Normal 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
19
assets/src/utils.ts
Normal 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());
|
||||
}
|
||||
149
assets/test/formatters/formatter.test.ts
Normal file
149
assets/test/formatters/formatter.test.ts
Normal 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);
|
||||
})
|
||||
});
|
||||
64
assets/test/formatters/intl-formatter.test.ts
Normal file
64
assets/test/formatters/intl-formatter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
374
assets/test/translator.test.ts
Normal file
374
assets/test/translator.test.ts
Normal 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
35
assets/test/utils.test.ts
Normal 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
49
composer.json
Normal 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
47
config/services.php
Normal 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
118
doc/index.rst
Normal 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
30
phpunit.xml.dist
Normal 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>
|
||||
45
src/CacheWarmer/TranslationsCacheWarmer.php
Normal file
45
src/CacheWarmer/TranslationsCacheWarmer.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
27
src/DependencyInjection/Configuration.php
Normal file
27
src/DependencyInjection/Configuration.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/DependencyInjection/TranslatorExtension.php
Normal file
38
src/DependencyInjection/TranslatorExtension.php
Normal 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
92
src/Intl/ErrorKind.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
1018
src/Intl/IntlMessageParser.php
Normal file
1018
src/Intl/IntlMessageParser.php
Normal file
File diff suppressed because it is too large
Load Diff
20
src/Intl/Location.php
Normal file
20
src/Intl/Location.php
Normal 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
24
src/Intl/Position.php
Normal 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
18
src/Intl/SkeletonType.php
Normal 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
61
src/Intl/Type.php
Normal 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
370
src/Intl/Utils.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/MessageParameters/Extractor/ExtractorInterface.php
Normal file
27
src/MessageParameters/Extractor/ExtractorInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
162
src/TranslationsDumper.php
Normal 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
29
src/TranslatorBundle.php
Normal 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__);
|
||||
}
|
||||
}
|
||||
49
tests/CacheWarmer/TranslationsCacheWarmerTest.php
Normal file
49
tests/CacheWarmer/TranslationsCacheWarmerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
383
tests/Intl/IntlMessageParserTest.php
Normal file
383
tests/Intl/IntlMessageParserTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
41
tests/Kernel/AppKernelTrait.php
Normal file
41
tests/Kernel/AppKernelTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
tests/Kernel/EmptyAppKernel.php
Normal file
35
tests/Kernel/EmptyAppKernel.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
47
tests/Kernel/FrameworkAppKernel.php
Normal file
47
tests/Kernel/FrameworkAppKernel.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 }",
|
||||
];
|
||||
}
|
||||
}
|
||||
127
tests/TranslationsDumperTest.php
Normal file
127
tests/TranslationsDumperTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
tests/TranslatorBundleTest.php
Normal file
26
tests/TranslatorBundleTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user