diff --git a/.gitignore b/.gitignore index ec5200ebd..88458b64e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ docker/rabbitmq/data # in this development bundle, we want to ignore directories related to a real app assets/* !assets/translator.ts +!assets/ux-translator migrations/* templates/* translations/* diff --git a/assets/translator.ts b/assets/translator.ts index ad2de8c0b..fe4e14ffa 100644 --- a/assets/translator.ts +++ b/assets/translator.ts @@ -1,9 +1,7 @@ -// @ts-ignore Cannot find module (when used within an app) -import { trans, setLocale, setLocaleFallbacks } from "@symfony/ux-translator"; +import { trans, setLocale, setLocaleFallbacks } from "./ux-translator"; setLocaleFallbacks({"en": "fr", "nl": "fr", "fr": "en"}); setLocale('fr'); export { trans }; -// @ts-ignore Cannot find module (when used within an app) export * from '../var/translations'; diff --git a/assets/ux-translator/README.md b/assets/ux-translator/README.md new file mode 100644 index 000000000..e6cad5f1a --- /dev/null +++ b/assets/ux-translator/README.md @@ -0,0 +1,3 @@ +This directory import the symfony ux-translator files directly into chill-bundles. + +This remove the yarn dependencies from the real package, which breaks our installation. diff --git a/assets/ux-translator/dist/formatters/formatter.d.ts b/assets/ux-translator/dist/formatters/formatter.d.ts new file mode 100644 index 000000000..c3403c041 --- /dev/null +++ b/assets/ux-translator/dist/formatters/formatter.d.ts @@ -0,0 +1 @@ +export declare function format(id: string, parameters: Record, locale: string): string; diff --git a/assets/ux-translator/dist/formatters/intl-formatter.d.ts b/assets/ux-translator/dist/formatters/intl-formatter.d.ts new file mode 100644 index 000000000..e22d7481a --- /dev/null +++ b/assets/ux-translator/dist/formatters/intl-formatter.d.ts @@ -0,0 +1 @@ +export declare function formatIntl(id: string, parameters: Record, locale: string): string; diff --git a/assets/ux-translator/dist/translator.d.ts b/assets/ux-translator/dist/translator.d.ts new file mode 100644 index 000000000..8d9f73e02 --- /dev/null +++ b/assets/ux-translator/dist/translator.d.ts @@ -0,0 +1,27 @@ +export type DomainType = string; +export type LocaleType = string; +export type TranslationsType = Record; +export type NoParametersType = Record; +export type ParametersType = Record | NoParametersType; +export type RemoveIntlIcuSuffix = T extends `${infer U}+intl-icu` ? U : T; +export type DomainsOf = M extends Message ? keyof Translations : never; +export type LocaleOf = M extends Message ? Locale : never; +export type ParametersOf = M extends Message ? Translations[D] extends { + parameters: infer Parameters; +} ? Parameters : never : never; +export interface Message { + id: string; + translations: { + [domain in DomainType]: { + [locale in Locale]: string; + }; + }; +} +export declare function setLocale(locale: LocaleType | null): void; +export declare function getLocale(): LocaleType; +export declare function throwWhenNotFound(enabled: boolean): void; +export declare function setLocaleFallbacks(localeFallbacks: Record): void; +export declare function getLocaleFallbacks(): Record; +export declare function trans, D extends DomainsOf, P extends ParametersOf>(...args: P extends NoParametersType ? [message: M, parameters?: P, domain?: RemoveIntlIcuSuffix, locale?: LocaleOf] : [message: M, parameters: P, domain?: RemoveIntlIcuSuffix, locale?: LocaleOf]): string; diff --git a/assets/ux-translator/dist/translator_controller.d.ts b/assets/ux-translator/dist/translator_controller.d.ts new file mode 100644 index 000000000..aff1f5870 --- /dev/null +++ b/assets/ux-translator/dist/translator_controller.d.ts @@ -0,0 +1 @@ +export * from './translator'; diff --git a/assets/ux-translator/dist/translator_controller.js b/assets/ux-translator/dist/translator_controller.js new file mode 100644 index 000000000..c4504b75e --- /dev/null +++ b/assets/ux-translator/dist/translator_controller.js @@ -0,0 +1,283 @@ +import { IntlMessageFormat } from 'intl-messageformat'; + +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 { + parts = id.match(/(?:\|\||[^|])+/g) || []; + } + const intervalRegex = /^(?({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?[[\]])\s*(?-Inf|-?\d+(\.\d+)?)\s*,\s*(?\+?Inf|-?\d+(\.\d+)?)\s*(?[[\]]))\s*(?.*?)$/s; + const standardRules = []; + for (let part of parts) { + part = part.trim().replace(/\|\|/g, '|'); + const matches = part.match(intervalRegex); + if (matches) { + const matchGroups = matches.groups || {}; + if (matches[2]) { + for (const n of matches[3].split(',')) { + if (number === Number(n)) { + return strtr(matchGroups.message, parameters); + } + } + } + else { + const leftNumber = '-Inf' === matchGroups.left ? Number.NEGATIVE_INFINITY : Number(matchGroups.left); + const rightNumber = ['Inf', '+Inf'].includes(matchGroups.right) + ? Number.POSITIVE_INFINITY + : Number(matchGroups.right); + if (('[' === matchGroups.left_delimiter ? number >= leftNumber : number > leftNumber) && + (']' === matchGroups.right_delimiter ? number <= rightNumber : number < rightNumber)) { + return strtr(matchGroups.message, parameters); + } + } + } + else { + const ruleMatch = part.match(/^\w+:\s*(.*?)$/); + standardRules.push(ruleMatch ? ruleMatch[1] : 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; + } +} + +function formatIntl(id, parameters, locale) { + 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); +} + +let _locale = null; +let _localeFallbacks = {}; +let _throwWhenNotFound = false; +function setLocale(locale) { + _locale = locale; +} +function getLocale() { + return (_locale || + document.documentElement.getAttribute('data-symfony-ux-translator-locale') || + (document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : null) || + 'en'); +} +function throwWhenNotFound(enabled) { + _throwWhenNotFound = enabled; +} +function setLocaleFallbacks(localeFallbacks) { + _localeFallbacks = localeFallbacks; +} +function getLocaleFallbacks() { + return _localeFallbacks; +} +function trans(message, parameters = {}, domain = 'messages', locale = null) { + if (typeof domain === 'undefined') { + domain = 'messages'; + } + 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); + } + } + if (_throwWhenNotFound) { + throw new Error(`No translation message found with id "${message.id}".`); + } + return message.id; +} + +export { getLocale, getLocaleFallbacks, setLocale, setLocaleFallbacks, throwWhenNotFound, trans }; diff --git a/assets/ux-translator/dist/utils.d.ts b/assets/ux-translator/dist/utils.d.ts new file mode 100644 index 000000000..b6621dfd6 --- /dev/null +++ b/assets/ux-translator/dist/utils.d.ts @@ -0,0 +1 @@ +export declare function strtr(string: string, replacePairs: Record): string; diff --git a/assets/ux-translator/package.json b/assets/ux-translator/package.json new file mode 100644 index 000000000..8fa315582 --- /dev/null +++ b/assets/ux-translator/package.json @@ -0,0 +1,34 @@ +{ + "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", + "scripts": { + "build": "node ../../../bin/build_package.js .", + "watch": "node ../../../bin/build_package.js . --watch", + "test": "../../../bin/test_package.sh .", + "check": "biome check", + "ci": "biome ci" + }, + "symfony": { + "importmap": { + "intl-messageformat": "^10.5.11", + "@symfony/ux-translator": "path:%PACKAGE%/dist/translator_controller.js", + "@app/translations": "path:var/translations/index.js", + "@app/translations/configuration": "path:var/translations/configuration.js" + } + }, + "peerDependencies": { + "intl-messageformat": "^10.5.11" + }, + "peerDependenciesMeta": { + "intl-messageformat": { + "optional": false + } + }, + "devDependencies": { + "intl-messageformat": "^10.5.11" + } +} diff --git a/package.json b/package.json index e2b8d8ba3..9493bd5dc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@hotwired/stimulus": "^3.0.0", "@luminateone/eslint-baseline": "^1.0.9", "@symfony/stimulus-bridge": "^3.2.0", - "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/webpack-encore": "^4.1.0", "@tsconfig/node20": "^20.1.4", "@types/dompurify": "^3.0.5", @@ -59,6 +58,7 @@ "bootstrap-icons": "^1.11.3", "dropzone": "^5.7.6", "es6-promise": "^4.2.8", + "intl-messageformat": "^10.5.11", "leaflet": "^1.7.1", "marked": "^12.0.2", "masonry-layout": "^4.2.2",